diff --git a/apps/dev/src/app/(payload)/admin/importMap.js b/apps/dev/src/app/(payload)/admin/importMap.js index 383f604c1..829286e7e 100644 --- a/apps/dev/src/app/(payload)/admin/importMap.js +++ b/apps/dev/src/app/(payload)/admin/importMap.js @@ -32,11 +32,8 @@ import { BlockLabelWithPresets as BlockLabelWithPresets_f0a4a6f21f15d606fa328a5e import { PresetAdminComponentPreview as PresetAdminComponentPreview_f0a4a6f21f15d606fa328a5e35f17d11 } from '@focus-reactive/payload-plugin-presets/client' import { PresetAdminComponentCellWrapper as PresetAdminComponentCellWrapper_f0a4a6f21f15d606fa328a5e35f17d11 } from '@focus-reactive/payload-plugin-presets/client' import { CommentsHeaderButton as CommentsHeaderButton_30d38dd40c31eff500900a16a2792204 } from '@focus-reactive/payload-plugin-comments/components/CommentsHeaderButton' -import { default as default_293abdab3a9d96b17cfed3bec5ca5deb } from '@focus-reactive/payload-plugin-analytics/components/AnalyticsView/AnalyticsHeaderLink' import { CommentsProviderWrapper as CommentsProviderWrapper_bc62ec20ac2037360812e296d7662f4a } from '@focus-reactive/payload-plugin-comments/providers/CommentsProviderWrapper' import { default as default_5668654bc04fc84f784cb30b290f6f3d } from '@focus-reactive/payload-plugin-translator/client/app/cache/CacheProvider' -import { default as default_52a4825d4da29270feb1d813ea3de6c8 } from '@/lead-actions-admin' -import { default as default_7772b5ceb4db588e7e8d7d6ad669ce76 } from '@focus-reactive/payload-plugin-analytics/components/AnalyticsView' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { SectionVisibilityLabel as SectionVisibilityLabel_c7986ed929437589fcd4621e9f64a5d3 } from '@/lib/section-visibility/SectionVisibilityLabel' @@ -76,11 +73,8 @@ export const importMap = { "@focus-reactive/payload-plugin-presets/client#PresetAdminComponentPreview": PresetAdminComponentPreview_f0a4a6f21f15d606fa328a5e35f17d11, "@focus-reactive/payload-plugin-presets/client#PresetAdminComponentCellWrapper": PresetAdminComponentCellWrapper_f0a4a6f21f15d606fa328a5e35f17d11, "@focus-reactive/payload-plugin-comments/components/CommentsHeaderButton#CommentsHeaderButton": CommentsHeaderButton_30d38dd40c31eff500900a16a2792204, - "@focus-reactive/payload-plugin-analytics/components/AnalyticsView/AnalyticsHeaderLink#default": default_293abdab3a9d96b17cfed3bec5ca5deb, "@focus-reactive/payload-plugin-comments/providers/CommentsProviderWrapper#CommentsProviderWrapper": CommentsProviderWrapper_bc62ec20ac2037360812e296d7662f4a, "@focus-reactive/payload-plugin-translator/client/app/cache/CacheProvider#default": default_5668654bc04fc84f784cb30b290f6f3d, - "@/lead-actions-admin#default": default_52a4825d4da29270feb1d813ea3de6c8, - "@focus-reactive/payload-plugin-analytics/components/AnalyticsView#default": default_7772b5ceb4db588e7e8d7d6ad669ce76, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, "@/lib/section-visibility/SectionVisibilityLabel#SectionVisibilityLabel": SectionVisibilityLabel_c7986ed929437589fcd4621e9f64a5d3 } diff --git a/apps/dev/src/integration/translator/auto-translate-only-changed.int.test.ts b/apps/dev/src/integration/translator/auto-translate-only-changed.int.test.ts new file mode 100644 index 000000000..482ad2d8f --- /dev/null +++ b/apps/dev/src/integration/translator/auto-translate-only-changed.int.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; + +// bootTestPayload caches one Payload per process, so a boot with different options needs its own file. + +const tr = (locale: string, text: string) => `${locale}:${text}`; + +describe("what auto-translate still does to a field nobody edited", () => { + let auto: TestPayload; + + beforeAll(async () => { + auto = await bootTestPayload({ autoTranslate: { targets: ["de"] } }); + }); + afterAll(async () => { + await auto?.cleanup(); + }); + + it("still overwrites a hand-corrected target when a sibling's source changes (overwrite, the default)", async () => { + const made = await auto.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Auto title", note: "Auto note" }, + }); + const id = String(made.id); + + await auto.payload.update({ + collection: "docs", + id, + locale: "de", + data: { _status: "published", title: "KORRIGIERT" }, + }); + await auto.payload.update({ + collection: "docs", + id, + locale: "en", + data: { _status: "published", title: "Auto title", note: "Auto note, rewritten" }, + }); + + const de = (await auto.payload.findByID({ collection: "docs", id, locale: "de" })) as Record< + string, + unknown + >; + expect(de.note).toBe(tr("de", "Auto note, rewritten")); + expect(de.title, "the correction is lost — this is the limitation, not the fix").toBe( + tr("de", "Auto title") + ); + }); +}); diff --git a/apps/dev/src/integration/translator/auto-translate-skip-existing.int.test.ts b/apps/dev/src/integration/translator/auto-translate-skip-existing.int.test.ts new file mode 100644 index 000000000..74ebcc712 --- /dev/null +++ b/apps/dev/src/integration/translator/auto-translate-skip-existing.int.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; + +// bootTestPayload caches one Payload per process, so a boot with different options needs its own file. + +const tr = (locale: string, text: string) => `${locale}:${text}`; + +describe("auto-translate on a collection that chose skip_existing", () => { + let auto: TestPayload; + + beforeAll(async () => { + auto = await bootTestPayload({ + autoTranslate: { targets: ["de"], strategy: "skip_existing" }, + }); + }); + afterAll(async () => { + await auto?.cleanup(); + }); + + it("refreshes the leaf whose source moved and leaves a hand correction alone", async () => { + const made = await auto.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Skip title", note: "Skip note" }, + }); + const id = String(made.id); + + await auto.payload.update({ + collection: "docs", + id, + locale: "de", + data: { _status: "published", title: "KORRIGIERT" }, + }); + await auto.payload.update({ + collection: "docs", + id, + locale: "en", + data: { _status: "published", title: "Skip title", note: "Skip note, rewritten" }, + }); + + const de = (await auto.payload.findByID({ collection: "docs", id, locale: "de" })) as Record< + string, + unknown + >; + expect(de.note, "its source moved").toBe(tr("de", "Skip note, rewritten")); + expect(de.title, "its source did not — and here the strategy is honoured").toBe("KORRIGIERT"); + }); +}); diff --git a/apps/dev/src/integration/translator/hand-edit-in-a-draft.int.test.ts b/apps/dev/src/integration/translator/hand-edit-in-a-draft.int.test.ts new file mode 100644 index 000000000..43974bc49 --- /dev/null +++ b/apps/dev/src/integration/translator/hand-edit-in-a-draft.int.test.ts @@ -0,0 +1,118 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +let ctx: TestPayload; + +const translate = ( + id: string, + strategy: "overwrite" | "skip_existing", + { publish }: { publish: boolean } +) => + callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "docs", + collection_id: [id], + strategy, + publish_on_translation: publish, + }, + }); + +const germanDraft = (id: string) => + ctx.payload.findByID({ + collection: "docs", + id, + locale: "de", + draft: true, + }) as Promise>; + +describe("a hand edit saved as a draft, then re-translated with skip_existing", () => { + beforeAll(async () => { + ctx = await bootTestPayload(); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("survives when the translation was published", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Source title", note: "Source note" }, + }); + const id = String(made.id); + + await translate(id, "overwrite", { publish: true }); + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { title: "de:Source title — PLUS MEINE WORTE" }, + draft: true, + }); + + await translate(id, "skip_existing", { publish: false }); + + expect((await germanDraft(id)).title).toBe("de:Source title — PLUS MEINE WORTE"); + }); + + it("survives when the translation was left as a draft", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Draft title", note: "Draft note" }, + }); + const id = String(made.id); + + await translate(id, "overwrite", { publish: false }); + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { title: "de:Draft title — PLUS MEINE WORTE" }, + draft: true, + }); + + await translate(id, "skip_existing", { publish: false }); + + expect((await germanDraft(id)).title).toBe("de:Draft title — PLUS MEINE WORTE"); + }); + + it("survives when ANOTHER English field is edited to make the document stale", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Stale title", note: "Stale note" }, + }); + const id = String(made.id); + + await translate(id, "overwrite", { publish: false }); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { title: "de:Stale title — PLUS MEINE WORTE" }, + draft: true, + }); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "en", + data: { _status: "published", title: "Stale title", note: "Stale note, rewritten" }, + }); + + await translate(id, "skip_existing", { publish: false }); + + const de = await germanDraft(id); + expect(de.note, "its source moved").toBe("de:Stale note, rewritten"); + expect(de.title, "its source did NOT move — the hand edit must stand").toBe( + "de:Stale title — PLUS MEINE WORTE" + ); + }); +}); diff --git a/apps/dev/src/integration/translator/per-field-receipt.int.test.ts b/apps/dev/src/integration/translator/per-field-receipt.int.test.ts new file mode 100644 index 000000000..ee019f5d3 --- /dev/null +++ b/apps/dev/src/integration/translator/per-field-receipt.int.test.ts @@ -0,0 +1,221 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +const PROV = "translator-provenance"; +const HASH_CHARS = 16; + +let ctx: TestPayload; + +const receipt = async (documentId: string) => { + const { docs } = await ctx.payload.find({ + collection: PROV, + where: { documentId: { equals: documentId } }, + pagination: false, + }); + return docs[0] as unknown as { id: string | number; sourceFingerprint: string } | undefined; +}; + +const translate = (id: string, strategy: "overwrite" | "skip_existing" = "overwrite") => + callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "docs", + collection_id: [id], + strategy, + publish_on_translation: true, + }, + }); + +const create = async (data: Record): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", ...data }, + }); + return String(made.id); +}; + +describe("the stored receipt", () => { + beforeAll(async () => { + ctx = await bootTestPayload(); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("is a JSON object keyed by each translatable leaf's address", async () => { + const id = await create({ title: "Receipt shape", note: "A note" }); + await translate(id); + + const row = await receipt(id); + const parsed = JSON.parse(row?.sourceFingerprint ?? "") as Record; + expect(Object.keys(parsed).sort()).toEqual(["note", "title"]); + const hashShape = new RegExp(`^[0-9a-f]{${HASH_CHARS}}$`, "u"); + expect( + Object.values(parsed).every((hash) => hashShape.test(hash)), + "computeFieldFingerprints owns the stored hash format" + ).toBe(true); + }); + + it("round-trips a document with 500 translatable leaves through the column", async () => { + const LEAVES = 500; + const MIN_ROUND_TRIP_BYTES = LEAVES * HASH_CHARS; + const id = await create({ + title: "Big", + items: Array.from({ length: LEAVES }, (_, index) => ({ label: `Label ${index}` })), + }); + await translate(id); + + const row = await receipt(id); + const stored = row?.sourceFingerprint ?? ""; + expect( + new TextEncoder().encode(stored).length, + "a fixture that is not actually large would prove nothing about the column" + ).toBeGreaterThan(MIN_ROUND_TRIP_BYTES); + + const parsed = JSON.parse(stored) as Record; + expect(Object.keys(parsed)).toHaveLength(LEAVES + 1); // the 500 labels plus `title` + }); + + it("reads a receipt written before per-field fingerprints, and upgrades it on the next run", async () => { + const id = await create({ title: "Legacy", note: "Legacy note" }); + await translate(id); + + const row = await receipt(id); + const legacyDocumentWideSha256 = "a".repeat(64); + await ctx.payload.update({ + collection: PROV, + id: row?.id as string | number, + data: { sourceFingerprint: legacyDocumentWideSha256 }, + }); + expect((await receipt(id))?.sourceFingerprint).toBe(legacyDocumentWideSha256); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { _status: "published", title: "KORRIGIERT" }, + }); + await ctx.payload.update({ + collection: "docs", + id, + locale: "en", + data: { _status: "published", title: "Legacy, edited", note: "Legacy note" }, + }); + + await translate(id, "skip_existing"); + + const de = (await ctx.payload.findByID({ collection: "docs", id, locale: "de" })) as Record< + string, + unknown + >; + expect(de.title, "nothing proved this leaf moved, so today's behaviour holds").toBe( + "KORRIGIERT" + ); + + expect( + (await receipt(id))?.sourceFingerprint, + "nothing was translated, so nothing new is claimed" + ).toBe(legacyDocumentWideSha256); + + await translate(id, "overwrite"); + const upgraded = (await receipt(id))?.sourceFingerprint ?? ""; + expect(upgraded.startsWith("{")).toBe(true); + expect(Object.keys(JSON.parse(upgraded) as Record).sort()).toEqual([ + "note", + "title", + ]); + }); + + it("still hides the indicator once an editor dismisses the drift", async () => { + const id = await create({ title: "Dismissed", note: "Dismissed note" }); + await translate(id); + await ctx.payload.update({ + collection: "docs", + id, + locale: "en", + data: { _status: "published", title: "Dismissed, edited", note: "Dismissed note" }, + }); + + const stale = async () => { + const res = await callEndpoint( + ctx.payload, + "get", + "/translate/stale/:collection_slug/:collection_id", + { routeParams: { collection_slug: "docs", collection_id: id } } + ); + const body = res.data as { data?: { locales: { is_stale: boolean }[] } } & { + locales?: { is_stale: boolean }[]; + }; + return (body.data?.locales ?? body.locales ?? [])[0]?.is_stale; + }; + + expect(await stale(), "the source moved, so the indicator is on").toBe(true); + + await callEndpoint(ctx.payload, "post", "/translate/stale/dismiss", { + body: { collection_slug: "docs", collection_id: id, target_lng: "de" }, + }); + + expect(await stale(), "acknowledged, so it hides").toBe(false); + }); + + it("does not leave the indicator stuck on after a run that skipped a leaf", async () => { + const id = await create({ title: "Partial run", note: "Partial note" }); + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { _status: "published", title: "VON HAND" }, + }); + + await translate(id, "skip_existing"); + + const stored = JSON.parse((await receipt(id))?.sourceFingerprint ?? "") as Record< + string, + string | null + >; + expect(Object.keys(stored).sort(), "every leaf of the source is accounted for").toEqual([ + "note", + "title", + ]); + expect(stored.title, "seen and declined, so nothing is claimed about it").toBeNull(); + expect(stored.note).toEqual(expect.any(String)); + + const res = await callEndpoint( + ctx.payload, + "get", + "/translate/stale/:collection_slug/:collection_id", + { routeParams: { collection_slug: "docs", collection_id: id } } + ); + const body = res.data as { data?: { locales: { is_stale: boolean }[] } }; + expect( + body.data?.locales[0]?.is_stale, + "the source has not moved since the run, so nothing is out of date" + ).toBe(false); + }); + + it("still lights the indicator when a leaf genuinely appears after the translation", async () => { + const id = await create({ title: "Grows later" }); + await translate(id, "overwrite"); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "en", + data: { _status: "published", title: "Grows later", note: "A note nobody translated yet" }, + }); + + const res = await callEndpoint( + ctx.payload, + "get", + "/translate/stale/:collection_slug/:collection_id", + { routeParams: { collection_slug: "docs", collection_id: id } } + ); + const body = res.data as { data?: { locales: { is_stale: boolean }[] } }; + expect(body.data?.locales[0]?.is_stale, "a new untranslated leaf is real drift").toBe(true); + }); +}); diff --git a/apps/dev/src/integration/translator/staleness-refresh.int.test.ts b/apps/dev/src/integration/translator/staleness-refresh.int.test.ts new file mode 100644 index 000000000..71f1120a6 --- /dev/null +++ b/apps/dev/src/integration/translator/staleness-refresh.int.test.ts @@ -0,0 +1,225 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +// #118: `skip_existing` refused to refresh a locale the admin calls out of date, because its only +// criterion was "is the target empty". + +const tr = (locale: string, text: string) => `${locale}:${text}`; + +let ctx: TestPayload; + +const create = async (data: Record): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", ...data }, + }); + return String(made.id); +}; + +const editSource = (id: string, data: Record) => + ctx.payload.update({ + collection: "docs", + id, + locale: "en", + data: { _status: "published", ...data }, + }); + +const translate = (id: string, strategy: "overwrite" | "skip_existing") => + callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "docs", + collection_id: [id], + strategy, + publish_on_translation: true, + }, + }); + +const keepingRowIds = (items: { id: string; label: string }[]) => + items.map(({ id, label }) => ({ id, label })); + +const german = (id: string) => + ctx.payload.findByID({ collection: "docs", id, locale: "de" }) as Promise< + Record + >; + +const english = (id: string) => + ctx.payload.findByID({ collection: "docs", id, locale: "en" }) as Promise< + Record + >; + +describe("a translation refreshes the leaf whose source moved, and only that one", () => { + beforeAll(async () => { + ctx = await bootTestPayload(); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("refreshes a stale leaf that skip_existing used to skip (#118)", async () => { + const id = await create({ title: "Original title", note: "Original note" }); + await translate(id, "overwrite"); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { _status: "published", title: "KORRIGIERT" }, + }); + await editSource(id, { title: "Original title", note: "Note, rewritten" }); + + await translate(id, "skip_existing"); + + const de = await german(id); + expect(de.note, "its source moved, so it is refreshed — this is what #118 asks for").toBe( + tr("de", "Note, rewritten") + ); + expect(de.title, "its source never moved, so the correction survives").toBe("KORRIGIERT"); + }); + + it("sends only the changed leaf to the provider", async () => { + const id = await create({ title: "Only one moves", note: "Steady" }); + await translate(id, "overwrite"); + + await editSource(id, { title: "Only one moves, edited", note: "Steady" }); + + const before = ctx.translateCount(); + await translate(id, "skip_existing"); + expect(ctx.translateCount() - before, "one provider call for the one run").toBe(1); + + const de = await german(id); + expect(de.title).toBe(tr("de", "Only one moves, edited")); + expect(de.note).toBe(tr("de", "Steady")); + }); + + it("leaves a filled-in target alone when NO receipt exists at all", async () => { + const id = await create({ title: "No receipt", note: "No receipt note" }); + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { _status: "published", title: "HAND-WRITTEN" }, + }); + + await translate(id, "skip_existing"); + + const de = await german(id); + expect(de.title, "no evidence its source moved, so the old promise holds").toBe("HAND-WRITTEN"); + expect(de.note, "empty, so it is filled in as it always was").toBe(tr("de", "No receipt note")); + }); + + it("translates only the inserted element when one is added at the head of an array", async () => { + const id = await create({ + title: "Array head", + items: [{ label: "First" }, { label: "Second" }], + }); + await translate(id, "overwrite"); + + const de = await german(id); + const germanItems = de.items as { id: string; label: string }[]; + expect(germanItems).toHaveLength(2); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { + _status: "published", + items: germanItems.map((item) => ({ ...item, label: `KORRIGIERT ${item.label}` })), + }, + }); + const en = (await english(id)) as { items: { id: string; label: string }[] }; + await editSource(id, { + title: "Array head", + items: [{ label: "Inserted" }, ...keepingRowIds(en.items)], + }); + + await translate(id, "skip_existing"); + + const after = (await german(id)) as { items: { label: string }[] }; + const labels = after.items.map((item) => item.label); + expect( + labels, + "a positional address would have shifted every sibling onto its neighbour" + ).toEqual([tr("de", "Inserted"), "KORRIGIERT de:First", "KORRIGIERT de:Second"]); + }); + + it("refreshes a changed leaf nested inside an array", async () => { + const id = await create({ + title: "Array staleness", + items: [{ label: "Alpha" }, { label: "Beta" }], + }); + await translate(id, "overwrite"); + + const en = (await english(id)) as { items: { id: string; label: string }[] }; + await editSource(id, { + title: "Array staleness", + items: [ + { id: en.items[0].id, label: "Alpha" }, + { id: en.items[1].id, label: "Beta, rewritten" }, + ], + }); + + const midway = (await german(id)) as { items: { label: string }[] }; + expect( + midway.items.map((item) => item.label), + "still the old translation, or the case below passes for reasons unrelated to the receipt" + ).toEqual([tr("de", "Alpha"), tr("de", "Beta")]); + + await translate(id, "skip_existing"); + + const after = (await german(id)) as { items: { label: string }[] }; + expect(after.items.map((item) => item.label)).toEqual([ + tr("de", "Alpha"), + tr("de", "Beta, rewritten"), + ]); + }); + + it("leaves a filled-in target alone when a receipt exists but has no entry for that leaf", async () => { + const id = await create({ title: "Partial receipt", note: "" }); + await translate(id, "overwrite"); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { _status: "published", note: "HAND-WRITTEN NOTE" }, + }); + await editSource(id, { title: "Partial receipt", note: "A note exists now" }); + + await translate(id, "skip_existing"); + + const de = await german(id); + expect(de.note, "the receipt says nothing about this leaf, so nothing is claimed").toBe( + "HAND-WRITTEN NOTE" + ); + }); + + // Fingerprinting the target instead would tell a human's text from ours, but a Payload round-trip + // can rewrite the stored value, so every leaf would read as hand-edited and refreshes would stop + // silently. + it("overwrites a hand-rewritten target once its own source moves — the trade this change makes", async () => { + const id = await create({ title: "Owned by a human", note: "Steady note" }); + await translate(id, "overwrite"); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "de", + data: { _status: "published", title: "VON HAND GESCHRIEBEN" }, + }); + await editSource(id, { title: "Owned by a human, edited", note: "Steady note" }); + + await translate(id, "skip_existing"); + + const de = await german(id); + expect(de.title, "the receipt cannot tell a human's text from ours, so the refresh wins").toBe( + tr("de", "Owned by a human, edited") + ); + }); +}); diff --git a/apps/dev/src/payload-types.ts b/apps/dev/src/payload-types.ts index b37eebfee..bfbcb16fe 100644 --- a/apps/dev/src/payload-types.ts +++ b/apps/dev/src/payload-types.ts @@ -1522,6 +1522,8 @@ export interface WorkflowTranslateDocumentLocales { | number | boolean | null; + requester_id?: string | null; + requester_collection?: string | null; }; } /** diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-02-per-field-fingerprints.md b/packages/payload-plugin-translator/docs/plans/2026-10-02-per-field-fingerprints.md new file mode 100644 index 000000000..702c84435 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-02-per-field-fingerprints.md @@ -0,0 +1,345 @@ +# Research — per-field fingerprints, so staleness can be answered per field (#118) + +**Status:** ready to implement. No blocking questions. +**Issue:** #118 — `skip_existing` ignores staleness, so the admin indicator and the strategy disagree. +**Package:** `packages/payload-plugin-translator`. +**Supersedes:** an earlier draft of this work that routed a changed-field list through the job row. +That approach was built to avoid a storage change which, on measurement, is not a storage change at +all (F1). It is abandoned: it fixed less and cost more. + +--- + +## Problem statement + +Two features in the plugin answer "does this need translating?" and give different answers. The admin +indicator says a locale is out of date; `skip_existing` then refuses to refresh it, because its only +criterion is whether the target field is empty. The endpoint returns `200` and nothing changes. + +They disagree because they work at different granularities. Staleness is one hash over the whole +document's translatable content, stored once per `(collection, document, targetLocale)`. The strategy +decides per field. There is no way to ask "did *this* field's source change", so the strategy asks the +only question it can — "is the target empty" — and that question has no notion of *current*. + +The same gap causes a second, unreported defect that is larger in practice: auto-translate's default +`overwrite` re-translates every field whenever any field changes, destroying human corrections to +fields whose source never moved. + +Both follow from one missing fact. Storing it is cheap. + +--- + +## Proposed scope + +**IN** + +1. Store a fingerprint per translatable leaf instead of one per document, in the existing + `sourceFingerprint` column, as JSON keyed by the leaf's id-based address. +2. Read records of either shape; a record written before this change keeps today's behaviour. +3. Give `FieldChunkCollector` the same id-based address the projector already produces, so the stored + map can be looked up at the point the strategy is consulted. +4. Extend the existing drift guard to compare **addresses**, not only texts. +5. Add the per-leaf staleness answer to `StrategyContext`. +6. Have the translate handler read the prior record before translating. +7. `skip_existing` translates a leaf whose target is empty **or** whose source changed since it was + translated. This is what #118 asks for. +8. Auto-translate translates only leaves that are stale, by the same mechanism. +9. `dismissedFingerprint` changes shape with `sourceFingerprint` (F5). +10. README, release notes, and an answer on #118. + +**OUT** + +- No change to the admin indicator's behaviour. `isRecordStale` must keep returning the same answer + for the same situation; only how it computes it changes. +- No new strategy, no new plugin option, no UI. +- No change to the job row's input shape. Nothing is added to the queue. +- The future per-field selection panel is not built, and nothing is shaped speculatively for it. + +### Non-functional scan + +| Dimension | Verdict | +|---|---| +| Performance | **Relevant, favourable.** Computing per leaf is free — the projector already walks them and only the final hash collapses the result. Restricting runs to stale leaves shrinks provider payloads, so provider cost falls. One extra read of the prior record per run. | +| Security / access control | **N/A.** No identity path changes; the provenance read already carries the requester after the access-control work. | +| Accessibility | **N/A.** No UI change. | +| i18n / localization | **Relevant.** Non-localized fields are already excluded from the projection; the map inherits that. | +| Observability | **Relevant.** A run that translates 2 of 40 leaves looks like a run that failed on 38. | +| Storage | **Relevant.** ~50–90 bytes per leaf instead of 64 bytes per document. Row count unchanged. See F3 for the ceiling. | + +--- + +## Codebase map + +### Provenance (what changes shape) + +| What | Where | +|---|---| +| Stored fields | `src/server/modules/provenance/Provenance.collection.ts:30-32` — `sourceFingerprint` (text, required), `dismissedFingerprint` (text) | +| Record type | `src/core/domain/provenance/ProvenanceStore.interface.ts:37-40` | +| Staleness rule | `src/core/domain/provenance/staleness.ts:21-28` (`isRecordStale`) — compares two strings | +| Dismiss | `ProvenanceStore.interface.ts:114` (`dismiss(key, dismissedFingerprint)`) | +| Store | `src/server/modules/provenance/Provenance.store.ts:44` | +| Service | `src/server/modules/provenance/Provenance.service.ts` — `captureFingerprint`, `record`, `getStaleness`, `dismiss` | + +### The projection (already per-leaf, already id-addressed) + +| What | Where | +|---|---| +| `projectTranslatableContent` | `src/core/domain/content-projection/contentProjector.ts:42-97` → `Array<{ idPath, text }>` | +| Entry shape | `contentProjector.ts:22-25` | +| `IdPath` grammar, sole constructor | `src/core/domain/content-projection/idPath.ts` — `makeIdPath`, `elementSegment`, escaping at `:33` | +| Leaf filter | `src/core/domain/content-projection/translatableLeaf.ts:17-31` — `text`/`textarea`/`richText`, localized, not excluded | +| `computeSourceFingerprint` | `computeSourceFingerprint.ts:18-23` | +| `fingerprint` | `fingerprinter.ts:34-41` — sorts by `idPath`, serializes, sha256 | + +### The pipeline (what must learn the address) + +| What | Where | +|---|---| +| Strategy consulted | `src/core/translation-pipeline/stages/field-collector/FieldChunkCollector.ts:112` | +| Positional path built | `FieldChunkCollector.ts:72`, `:98` (`String(index)`), `:118` | +| `FieldChunk` | `src/core/translation-pipeline/types/FieldChunk.ts` | +| `StrategyContext` | `src/core/translation-pipeline/strategies/TranslationStrategy.interface.ts:4-7` | +| Strategies (both) | `Overwrite.strategy.ts:9-11`, `SkipExisting.strategy.ts:11-24` | +| Drift guard | `src/core/translation-pipeline/stages/field-collector/driftGuard.test.ts` | +| Handler-facing input | `src/core/translation-pipeline/translateContent.ts:8-35` | +| `PipelineConfig` | `src/core/translation-pipeline/types/Pipeline.ts` | + +### Callers + +| What | Where | +|---|---| +| Translate handler — holds the service, captures before, records after | `src/server/features/translate-document/handler.ts:114-115`, `:139-141` | +| Auto-translate hook | `src/server/modules/auto-translate/AutoTranslateEnqueue.hook.ts:31-98`; gate at `:66` | +| Auto-translate policy | `src/server/modules/auto-translate/AutoTranslate.policy.ts` — strategy default `"overwrite"` at `:33` | +| Manual enqueue endpoint | `src/server/features/enqueue-translation/handler.ts:83-93` | + +### Prior art + +- `docs/plans/2026-06-30-slice6-contentprojector-idpath-design.md` — created `IdPath` and **deferred** + re-keying the pipeline onto it: *"deferred until something needs it — likely never for + correctness."* This task is what needs it. Reopening that deferral is in scope. +- `docs/plans/2026-09-08-one-live-job-per-document.task.md` — untouched here; nothing is added to the + job row. +- No prior plan document and no reverted attempt for #118. + +--- + +## Findings + +**F1 — this is not a schema change, and the issue's own cost estimate is wrong.** +#118 states that per-field fingerprints "changes the provenance collection's shape and needs a +migration". The field is `{ name: "sourceFingerprint", type: "text" }` +(`Provenance.collection.ts:30`). Payload's `text` maps to an unbounded `varchar` on Postgres, `text` +on SQLite, a string on Mongo. Putting a different string into a text column is not a schema change: +no DDL, no migration, no backfill. The migration was the expensive part of the issue's estimate, and +it does not exist. + +**F2 — computing per leaf is free; only the final hash collapses it.** +`computeSourceFingerprint` is `fingerprint(projectTranslatableContent(doc, schema))`, and the +projector already emits one `{ idPath, text }` per translatable leaf. The per-leaf data is produced +today and discarded. + +**F3 — size, measured against the real limits.** +Per entry: address 20–60 chars + hash + JSON punctuation ≈ 50–90 bytes. + +| Translatable leaves | Map size | +|---|---| +| 40 | ~3 KB | +| 500 | ~35 KB | +| 5 000 | ~350 KB | + +Limits: Postgres unbounded `varchar` → 1 GB; SQLite → ~1 GB; **Mongo → the 16 MB document limit, which +is the binding one.** At ~90 bytes per leaf that is ~180 000 leaves in one document. + +The ceiling is also self-limiting: the entry count equals the translatable-leaf count, which is exactly +the number of texts sent to the provider. The map cannot grow independently of work already being paid +for. + +**F4 — a wrong address fails safe; a colliding address would not, and is prevented.** +If an address changes when it should not (schema rename, element deleted and recreated, block type +changed), the old key is orphaned and the new key is absent — the leaf reads as never translated and +is translated. The cost is a redundant provider call, never a skipped update. + +The dangerous direction is two distinct leaves rendering to one address, which would mask one behind +the other. `makeIdPath` escapes `\ # . :` (`idPath.ts:33`) precisely so a field name or id cannot forge +a separator. + +Payload guarantees the ids this rests on: `fields/config/sanitize.js:124-128` pushes an `id` field onto +every array that lacks one, and `baseIDField` carries both a `defaultValue` and a +`beforeChange: value || new ObjectId()`. `elementSegment` falls back to a positional segment when an id +is absent — again the safe direction. + +**F5 — `dismissedFingerprint` must change shape with `sourceFingerprint`.** +`isRecordStale` (`staleness.ts:21-28`) is two string comparisons: +`current !== record.sourceFingerprint && current !== record.dismissedFingerprint`. If the source side +becomes a map and the dismissed side stays a single hash, the second comparison can never match again +and **dismissing silently stops working** — the indicator would never hide. The two fields are one +mechanism and move together. + +**F6 — the drift guard exists but does not guard what this task depends on.** +`driftGuard.test.ts` runs both the projector and the collector over one fixture and asserts they agree +on the **set of texts**, the **count** of leaves, and the **exclusions**. It never compares addresses — +and cannot today, because only one side has an id-based address. So it stays green while the two sides +disagree about addresses, which is exactly the present state. The address is about to become the join +key between stored data and the running pipeline; the guard must be extended or it gives false comfort. + +**F7 — the handler is already positioned for the extra read.** +`translate-document/handler.ts:114-115` already resolves the provenance service and computes a +fingerprint of the source *before* translating, and records it after success at `:139-141`. What is +missing is a read of the prior record. + +--- + +## Design constraints (binding on implementation) + +**D-A — tests red first.** Every criterion below is written as a test that fails against the current +tree before the change, and the failure is captured. A test that is green on first run proves nothing +about what it guards. + +**D-B — the long-value tests run on all three adapters.** The claim in F1/F3 — that a multi-kilobyte +JSON string round-trips through the `sourceFingerprint` column — is a claim about three different +databases and must be measured on each (SQLite, PostgreSQL, MongoDB), not reasoned about from Payload's +field type. These are integration tests in `apps/dev/src/integration/translator/`. + +**D-C — the two stored shapes are distinguished by the type system, not by a runtime sniff.** +"Legacy single hash" and "per-field map" are a discriminated union parsed once at the boundary, so the +compiler forces every reader to handle both. Scattered `startsWith("{")` checks are not acceptable: +they put the decision in N places and make the legacy path invisible to the type-checker. + +--- + +## Acceptance criteria (draft) + +Each is red before the change; the red run is captured. + +1. **Given** a document translated to `de`, **when** only the English `body` is edited and a + `skip_existing` run executes, **then** the German `body` is retranslated and the German `title` is + byte-identical to its prior value. *(This is #118's reproduction.)* + *Checked by:* integration test, all three adapters. +2. **Given** the same edit, **then** the provider receives only the `body` text. + *Checked by:* the harness's provider call counter / recorded payload. +3. **Given** auto-translate configured with the default `overwrite`, **when** one field's source + changes, **then** only that field is translated and the others keep their target values. + *Checked by:* integration test. +4. **Given** a provenance record written before this change (a bare 64-char hex hash), **when** a + translation runs, **then** it behaves exactly as today and the record is upgraded to the map form on + write. + *Checked by:* unit test on the parser plus an integration test seeding a legacy row. +5. **Given** a document with ~500 translatable leaves, **when** it is translated, **then** the stored + `sourceFingerprint` round-trips intact and parses back to the same map. + *Checked by:* integration test on **each** of SQLite, PostgreSQL, MongoDB (D-B). +6. **Given** an element inserted at the head of a localized array, **when** a run executes, **then** + only the new element's leaves are translated and its siblings' target values are untouched. + *Checked by:* integration test. This fails if addresses are positional. +7. **Given** the fixture in `driftGuard.test.ts`, **then** the projector and the collector produce the + **same set of addresses**, not merely the same texts. + *Checked by:* the new assertion in that file (F6). +8. **Given** an editor dismissed a staleness warning, **when** the source has not changed since, + **then** the indicator stays hidden — dismissal still works after the shape change. + *Checked by:* unit test on `isRecordStale` plus an integration test (F5). +9. **Given** any record, **when** the admin indicator is computed, **then** it reports the same + staleness as before this change for the same situation. + *Checked by:* the existing staleness tests must pass unmodified. +10. **Given** a field removed from the schema, **when** the document is translated again, **then** its + orphaned address is not carried into the newly written map. + *Checked by:* unit test. +11. **Negative path — given** a stored value that is neither a legal map nor a legal hash, **when** it + is read, **then** the record is treated as absent (translate everything) and no exception escapes. + *Checked by:* unit test. +12. README, release notes and the #118 answer state the new behaviour, including that `skip_existing` + now refreshes a changed field. + *Checked by:* reading the files. + +--- + +## Open questions + +### Blocking + +None. + +### Non-blocking — each carries a default + +1. **Hash length per leaf.** sha256 is 64 hex chars; truncating to 16 halves the map. + **Default:** truncate to 16, pinned in the type, with the choice recorded. Collision risk at this + scale is not meaningful, and the value is a change-detector, not a security token. +2. **Mongo's 16 MB ceiling.** Reachable only at ~180 000 leaves in one document (F3). + **Default:** do not cap. Record the arithmetic and let AC 5 measure the realistic case. A cap is + speculative machinery for a document that cannot be translated anyway. +3. **Is the restricted set logged?** + **Default:** one debug-level line with the count of leaves selected, at the point the set is applied. +4. **Publish after draft saves.** For a drafts collection the hook proceeds only when + `doc._status === "published"` (`AutoTranslate.policy.ts:97-105`), and on update `previousDoc` is the + prior stored row, which after draft saves already holds the new text. Auto-translate may therefore + not fire at all. **This exposure exists today and is not created by this work** — and with per-leaf + staleness read from the record rather than from `previousDoc`, item 8 of scope may incidentally + change it. **Default:** write the test to establish the present fact first, then state plainly in + the contract whether the change moved it. Do not claim a fix that was not designed. +5. **`AutoTranslateConfig.strategy`.** With auto-translate restricted to stale leaves, `overwrite` and + `skip_existing` converge there. + **Default:** leave it working, mark it `@deprecated` in the type naming its replacement and removal + point. The repository already uses this device (`src/plugin.ts:120` and four more). No runtime + warning machinery: the whole strategy family is slated for removal when auto-translate configuration + moves from the developer to the editor, and warnings for one member of a family being deleted + wholesale is work that gets thrown away. +6. **The collector's positional `path`.** It has no production reader and 28 assertions in + `FieldChunkCollector.test.ts`. + **Default:** replace it with the id-based address rather than adding a second identifier; rewrite the + assertions. A field nothing reads is how the present wrong one survived. + +### Restate check + +- **"Per-field fingerprint"** means a hash of one translatable leaf's extracted source *text*, keyed by + its `IdPath`. Not a hash of the field's raw value, and not the text itself — storing text would + duplicate document content into a second table. +- **"Stale leaf"** means: its address is absent from the stored map (never translated), or its stored + hash differs from the hash of its source text now. +- **"Legacy record"** means a `sourceFingerprint` holding the current single document-wide hash. It is + read, honoured, and replaced with a map the next time that document-locale is translated. Nothing is + backfilled. + +--- + +## Risks & constraints + +- **A stored-format change without a migration is still a stored-format change.** The legacy path is + the highest-risk surface here, which is why D-C puts both shapes in the type system and AC 4 and + AC 11 are red-first. +- **The translation hot path.** `FieldChunkCollector` runs on every translation, manual and automatic. +- **`core` contracts.** `StrategyContext` gains a field; both strategies implement the interface. +- **`core` stays Payload-free.** The parser, the map type and the staleness rule belong beside the + existing pure code in `core/domain/`. +- **Three adapters.** Baselines before this work: SQLite 160, PostgreSQL 166, MongoDB 159 passing of + 167. Unit baseline 1842; `oxlint` background exactly 55 warnings, 0 errors. +- **Released behaviour changes.** `skip_existing` begins refreshing changed fields, and auto-translate + stops rewriting untouched ones. Both belong in release notes. + +### Consistency self-check + +- Every IN-scope item has a criterion: 1→AC 5; 2→AC 4, 11; 3→AC 6; 4→AC 7; 5→AC 1; 6→AC 1; 7→AC 1, 2; + 8→AC 3; 9→AC 8; 10→AC 12. AC 9 and AC 10 guard OUT-of-scope invariants. +- No criterion contradicts an OUT-of-scope line; AC 9 enforces one. +- Everything traces to a file:line, to the issue, or to a measurement above. Marked inference: the + claim that a per-leaf debug line is needed to tell a restricted run from a broken one rests on the + absence of any per-field logging today. + +--- + +## Readiness + +- Unresolved **blocking** questions: **0**. +- IN-scope items with no acceptance criterion: **0**. + +**Ready.** Six remaining questions all carry defaults and none changes the shape of the work. + +## Suggested next step + +**Ready for the implementation workflow.** Risk is **high**: a stored-format change with a legacy path, +a `core` contract change, the translation hot path, and 28 existing assertions to rewrite. + +No system-level design pass is needed — placement is forced at every step (the map and its parser beside +the existing pure provenance code, the address from the existing sole constructor, the staleness input +at the one place the strategy is already consulted) — with one exception worth naming: **the stored +format and its legacy path are a data-model decision**, and if the implementation finds the +discriminated union fighting the storage layer, that is the signal to stop and design rather than +improvise. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-02-per-field-fingerprints.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-02-per-field-fingerprints.task.md new file mode 100644 index 000000000..17266ee36 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-02-per-field-fingerprints.task.md @@ -0,0 +1,501 @@ +# Task contract — per-field fingerprints, so staleness can be answered per field (#118) + +**Risk: HIGH.** Changes a stored format with a legacy path, changes a `core` contract two strategies +implement, touches the translation hot path, and rewrites 28 existing assertions. + +**Research:** `docs/plans/2026-10-02-per-field-fingerprints.md` — its codebase map and findings F1–F7 +are adopted, not re-derived. Phase 1 below records only what verification added or corrected. + +--- + +## Phase 1 — what verification added + +The research map held. Four things it did not have, all of which change the design: + +**V1 — `computeSourceFingerprint` must not change its return type.** Three production callers +(`hasSourceContentChanged.ts:30`, `Provenance.service.ts:70`, `:202`) and it is **public API** +(`core/index.ts:28`). `hasSourceContentChanged` compares two of its results with `!==`; returning an +object there makes every comparison reference-unequal, so every save would look changed and +auto-translate would fire on all of them. The per-field map therefore ships as a **new sibling +function**, and `computeSourceFingerprint` is untouched. + +**V2 — `Provenance.store.ts:40` is the parse boundary, and today it erases types.** +`toRecord` does `sourceFingerprint: String(doc.sourceFingerprint)` and the same for +`dismissedFingerprint` at `:44-45`. Keeping the column `type: "text"` means Payload hands back a +string and `String()` is a no-op — harmless. Switching the field to `type: "json"` would make +`String()` produce `"[object Object]"` silently, with no throw, and every record would then read as +stale forever. This is the concrete reason the column stays `text` (and `Provenance.collection.ts:10-12` +already chose `text` for cross-adapter portability). + +**V3 — the admin indicator breaks unless `isRecordStale` learns both shapes.** It compares the stored +string against a freshly computed one. A stored map vs. a computed document hash is never equal, so +every locale would read stale. `isRecordStale` must take both current values and branch on the stored +shape. + +**V4 — the existing `skip_existing` tests survive the redefinition.** `strategies.int.test.ts`, +`strategy-publish-matrix.int.test.ts` and `draft-safe-writes.int.test.ts` enqueue manually with no +prior translation, so there is no record, so `sourceChanged` is unknown, so D4's safety rule leaves +them on today's behaviour. Verified by reading them; the suite run is the proof (AC 13). + +**Baselines, measured on the untouched tree:** unit 1842 in 134 files · `check-types` clean · +`oxlint` 55 warnings 0 errors · integration SQLite 160 passed / 7 skipped of 167. Postgres (5434) and +Mongo (27017) are up, so all three adapters are runnable. + +**Precedent adopted:** `field-surface-errors.int.test.ts` proves a fixture is actually large +(`TextEncoder().encode(...).length` asserted against the threshold) *before* asserting behaviour on it. +AC 5 follows that order. `describe.skipIf` is the adapter-gating idiom, but AC 5 deliberately does not +use it — the point is that it runs on all three. + +--- + +## Design decisions + +**D1 — the stored value stays one `text` column; the two shapes are a discriminated union parsed at +the store boundary.** + +```ts +export type SourceFingerprint = + | { kind: "document"; hash: string } // legacy: the single sha256 written until now + | { kind: "fields"; hashes: FieldFingerprints }; // JSON object, IdPath -> hash +``` + +`PayloadProvenanceStore.toRecord` parses on read; the store serializes on write. +`TranslationProvenanceRecord.sourceFingerprint` becomes `SourceFingerprint`, and +`dismissedFingerprint` becomes `SourceFingerprint | null`. Every reader is then forced by the compiler +to handle both. + +Chosen over a `startsWith("{")` test at each use — the owner's constraint, and V2 shows why a silent +mis-read here is invisible rather than loud. Rejected: changing the field to `type: "json"` (V2: the +`String()` coercion corrupts it without throwing, and the collection deliberately uses only +`text`/`date` for cross-adapter portability). Rejected: a `"v": 1` version marker in the JSON — no +second format is in hand, and the union already discriminates; recorded here so the next person knows +it was weighed, not missed. + +**D2 — a new `computeFieldFingerprints`, beside `computeSourceFingerprint`, which is untouched.** +Cites V1. One extra walk of a projection the projector already produces; no change to the public +export and no change to the auto-translate drift gate. + +**D3 — staleness travels into the pipeline as a decided tri-state per leaf, not as the raw map.** + +`StrategyContext` gains `sourceChanged?: boolean`, with three meanings that are all safe by default: + +| value | means | because | +|---|---|---| +| `true` | we hold a record and the leaf's source hash differs | refresh it | +| `false` | we hold a record and the hash matches | it is current | +| `undefined` | no record, a legacy record, or no address match | **we do not know — claim nothing** | + +The comparison stays in one place (`core/domain/provenance`), so no second hash implementation exists +to drift from the first. The collector only needs the leaf's address and a lookup. + +Rejected: hashing each leaf inside `FieldChunkCollector` and comparing there — it puts a second copy +of the hashing rule on the hot path, and the two copies are exactly what F6's guard exists to prevent. + +**D4 — unknown is never stale. This is the safety rule the legacy path rests on.** +`skip_existing` refreshes a leaf only on `sourceChanged === true`. A document with no provenance, or +with a legacy record, yields `undefined` everywhere and therefore behaves **exactly as today** — a +hand-written translation on a document the plugin never translated is not overwritten. The record +upgrades to the map form the next time that document-locale is actually translated. + +Rejected: treating a legacy record's document-level drift as "every leaf is stale". That is the +destruction #118 itself warns about, reached from a new direction. + +**D5 — the record is merged on write, not replaced.** +`skip_existing` may translate two leaves of forty. Writing the whole current map would claim the other +thirty-eight were translated from the current source — false, and the same lie today's single +document-wide hash already tells. So: entries for leaves actually translated are updated, entries for +untouched leaves are preserved, and addresses absent from the current projection are dropped (which is +what retires a field removed from the schema). + +This requires the pipeline to report which leaves it translated: `PipelineResult` gains +`translatedPaths: IdPath[]`. It has one field today (`translatedData`), and the collector already +builds the list. + +**D6 — WITHDRAWN. Auto-translate keeps asking for the strategy the collection configured.** + +It was taken, built, and reversed by measurement. As built, an automatic run used `skip_existing` +semantics: a filled-in target with no receipt reads as *unknown*, and unknown never claims a change, +so the leaf was left alone. Five existing integration suites caught it — they seed the German locale +with a sentinel and publish an English edit expecting it to be overwritten. The consequence was wider +than those tests: **auto-translate would stop writing any target field filled in by hand before the +plugin ever translated it, permanently**, because no receipt can ever appear for a leaf nothing +translates. + +A replacement was measured and also rejected: teaching `OverwriteStrategy` to skip a leaf the receipt +proves current. It passes every suite (171/178 on SQLite, no failures) and it does fix the larger +defect — but it is the plugin **guessing** that an unchanged source means the editor wants no +re-translation. That guess is precisely what the owner's intended direction (a per-field opt-in the +editor controls) exists to replace with an answer, so buying it now would be building the thing we +plan to delete. + +**What this task fixes, then, is the contradiction and nothing else.** `skip_existing` refreshes a leaf +whose source moved — on the manual path, which is where #118 is reported, and on the automatic path +for a collection that chose it. `overwrite` is untouched. + +**D6a — WITHDRAWN with D6.** No `@deprecated` tag, no ledger entry, no boot warning, no README table +change: the option still means what it says. The whole strategy family goes when the per-field opt-in +lands, in one removal rather than a drip. + +**The limitation this leaves is pinned as a test, not as prose.** +`apps/dev/src/integration/translator/auto-translate-only-changed.int.test.ts` asserts that `overwrite` +— the default — still overwrites a hand-corrected target when a sibling's source changes, and says in +its comment why, and what to rewrite it to when the opt-in lands. A limitation in a green run cannot be +forgotten the way a README paragraph can. + +### Placement + +| Piece | Lands in | Why there | +|---|---|---| +| `computeFieldFingerprints` | `core/domain/content-projection/` | beside the projector whose output it consumes | +| `SourceFingerprint` union, parse, serialize | `core/domain/provenance/SourceFingerprint.ts` | it is the stored shape; the role-tag rules leave small pure helpers untagged (`staleness.ts` precedent) | +| `sourceChanged` decision | `core/domain/provenance/staleness.ts` | the one owner of the comparison rule already | +| parse/serialize calls | `server/modules/provenance/Provenance.store.ts` | the single Payload boundary (V2) | +| address on the chunk | `core/translation-pipeline/.../FieldChunkCollector.ts` | where the walk already is | + +### New surface + +- `computeFieldFingerprints` — callers: `Provenance.service.captureFingerprint`, `makeCurrentFingerprint`. Two, both existing. +- `SourceFingerprint` + parse/serialize — callers: `Provenance.store.toRecord`, `Provenance.store.upsert`/`dismiss`, `staleness.ts`. Three, all existing. +- `PipelineResult.translatedPaths` — caller: `translate-document/handler.ts` (for D5's merge). One caller, and it is the reason the field exists; not speculative. + +### Written contract + +Yes, and it is the reason `/sp-red-test` applies in Phase 3: `SourceFingerprint`'s parse owes callers +what an unparseable value means, what an absent address means, and that `undefined` is never stale +(D4). None of that is expressible in the signature. + +### Escalate to an architecture pass? + +**No — with one tripwire.** Placement is forced at every step and no new dependency, module or seam +appears. But the stored format with a legacy path is a data-model decision: if the union turns out to +fight the storage layer during Phase 3, that is the signal to stop and design, not to improvise. + +--- + +## Acceptance criteria + +Every criterion is red before the change and its red run is captured (owner constraint). + +| # | Criterion | Declared check | +|---|---|---| +| 1 | #118's reproduction: a translated `de` document, English `body` edited, a `skip_existing` run → German `body` refreshed, German `title` byte-identical | integration, all three adapters | +| 2 | The provider receives only the changed leaf's text in that run | `translateCount()` / recorded payload | +| 3 | ~~Auto-translate with the default config translates only the changed field~~ — **withdrawn with D6.** Replaced by: the default's existing behaviour is pinned as a known limitation, and a collection on `skip_existing` gets its stale leaves refreshed | `auto-translate-only-changed.int.test.ts`, both cases | +| 4 | A legacy record (bare 64-hex) → behaviour identical to today, and the record is upgraded to the map form on the next successful translation | unit on the parser + integration seeding a legacy row | +| 5 | A document with ~500 translatable leaves: the stored value round-trips and re-parses to the same map, with the fixture's byte size asserted first | integration on **each** of SQLite, PostgreSQL, MongoDB | +| 6 | An element inserted at the head of a localized array → only the new element's leaves are translated, siblings untouched | integration (fails if the address is positional) | +| 7 | Projector and collector produce the **same set of addresses**, not only the same texts | the new assertion in `driftGuard.test.ts` | +| 8 | A dismissed staleness warning stays hidden when the source has not changed since | unit on `isRecordStale` + integration | +| 9 | The admin indicator reports the same staleness as before for the same situation: every case in `staleness.test.ts` keeps its verdict, and a legacy-shaped record yields exactly today's answers | `staleness.test.ts` cases preserved verdict-for-verdict (the fixtures adapt to the new input shape — see the note below), plus the HTTP-level `features/staleness/staleness.test.ts` | +| 10 | A field removed from the schema leaves no orphaned address in the newly written map | unit | +| 11 | No provenance record at all → `skip_existing` behaves exactly as today (D4) | unit + integration | +| 12 | Unhappy path: a stored value that is neither a legal map nor a legal hash → treated as absent, no exception escapes | unit | +| 13 | The existing `skip_existing` suites still pass unmodified (V4) | `strategies`, `strategy-publish-matrix`, `draft-safe-writes` integration specs | +| 14 | ~~`AutoTranslateConfig.strategy` is deprecated~~ — **withdrawn with D6a.** The option still applies, so there is nothing to deprecate | +| 15 | README, release notes and the #118 answer state the new behaviour, including that `overwrite` is unchanged | reading the files — **text proposed to the owner, not applied** | + +**Note on AC 9, corrected during pre-flight.** It first read "the existing staleness tests pass +unmodified". That is not achievable and saying so now is cheaper than discovering it at grading time: +`isRecordStale` takes the current fingerprint as a parameter, so changing what a fingerprint *is* +changes its signature, and its test fixtures with it. The invariant that actually matters — and that +AC 9 now states — is that each existing case keeps its **verdict**, and that a legacy-shaped record +produces exactly today's answers. The fixtures may be re-typed; no case may change its expected +result, and none may be deleted. + +**Outcomes are three: met · not met · not verified.** A criterion whose declared check could not be run +is recorded `not-verified`, never `met` via a weaker proxy. + +### Criteria pre-flight (against the untouched tree) + +To be run and recorded before the first edit. Expected polarity: 1–8, 10–12, 14, 15 must **fail** now +(they describe what must change); 9 and 13 must **pass** now (they describe what must stay true). + +--- + +## Human choices + +- **The approach itself was reversed mid-research, by the owner's challenge.** An earlier plan routed a + changed-field list through the job row to avoid a storage change. On measurement there is no storage + change (F1), so that plan fixed less and cost more. Recorded so it is not re-proposed. +- **"Reject new values, honour old ones" for the deprecated option is not expressible** and was dropped + for that reason: the option is configuration in code, re-supplied from source on every boot, so no + older value exists to tell from a newer one. A type-level deprecation is the one mechanism that does + express it. +- **#118 is not closed by scope alone.** The issue's reproduction is the *manual* run; items 1 and 2 + address it directly. The larger unreported defect — auto-translate's default destroying untouched + translations — is item 3. Both are claimed only where measured. +- **The D6 decision was reversed after it was built, by the owner.** The fork first put to them + ("fix the bigger defect" vs "honour the setting") dissolved once measurement showed the first option + silently stopped auto-translate writing hand-filled targets. The owner's direction — simplify, stop + guessing, hand per-field control to the editor later — made the narrow close the right one. The + larger defect stays open **knowingly**, pinned by a test. +- **The trade `skip_existing` makes was put to the owner and accepted (option A).** A leaf a human + rewrote is indistinguishable from one the machine wrote — the receipt records what the *source* said, + never who authored the target — so when that leaf's source moves, the refresh overwrites the human's + text. Before per-field receipts it survived, because a non-empty target was skipped unconditionally. + **This is a regression for `skip_existing` users in exactly one case, and it belongs in the release + notes in plain words.** It buys the resolution of #118: the indicator and the strategy stop + contradicting each other. + + The rejected alternatives: leave `skip_existing` alone (#118 stays open, nothing is gained) and + record a second per-leaf hash of *what we wrote into the target* (no guessing at all, but a + round-trip through Payload — lexical normalisation, publish — can change the text we would read + back, making every leaf look hand-edited and stopping refreshes **silently**; that is its own piece + of work with its own measurement). + + The real answer is the per-field opt-in the owner plans: an editor marking a field "do not + auto-translate" is a statement, not an inference from hashes. The interim second hash would be + thrown away by it. + + Pinned by `staleness-refresh.int.test.ts`, "overwrites a hand-rewritten target once its own source + moves — the trade this change makes", whose comment says what to rewrite it to when the opt-in lands. +- **Three owner constraints bind every phase:** red tests first with the red run captured · the long-value + test runs per adapter, not reasoned about · the two stored shapes are a typed union parsed once. +- **The D6 fork was put to the owner and decided for scope item 8**, i.e. the bigger unreported defect + (auto-translate's default destroying untouched translations) is fixed and + `AutoTranslateConfig.strategy` goes inert. The rejected alternative — honour the option, drop item 8 — + would have left every host on the default still losing hand-corrections. The owner's condition was + that an inert option must be retired honestly rather than quietly ignored; that is D6a. +- **Scope is one task, one merge**, decided by the owner: the foundation has nothing to verify until + the strategy and auto-translate consume it, so a first merge would close no acceptance criterion. + +## Progress log — Phase 3 + +Red runs captured in `docs/plans/artifacts/2026-10-02-red-runs.txt`. Every unit below went red on a +stub or against the unchanged code before it was implemented. + +| Unit | Red | Green | Mutations | +|---|---|---|---| +| `computeFieldFingerprints` | 6/6 on a stub, 0 passed | 6/6 | — | +| `SourceFingerprint` parse/serialize | 23/23 on a stub, 0 passed | 23/23 | 3 run; **one survived and found dead code** (an unreachable `Array.isArray` guard behind a redundant `startsWith("{")` prefilter). Prefilter removed; the mutation then killed a case. | +| `isRecordStale` + `sourceChangedAt` | 17/17 on a stub, 0 passed | 17/17 | 4 run, 4 killed — including both halves of D4's safety rule | +| `PayloadProvenanceStore` parse boundary | 8 failed / 11 passed (the 11 are key and transaction cases, correctly untouched) | 19/19 | — | +| `ProvenanceService` merge-on-write | 7 failed / 8 passed | 15/15 | — | +| drift guard — address agreement | 1 failed on real values (`content.#0.heading` vs `content.b1:hero.heading`) | 4/4 | 1 run, 1 killed | +| `FieldChunkCollector` address | 24 failed / 17 passed | 47/47 | — | +| `SkipExisting` refresh-on-change | 1 failed (the only behaviour change; the four safety cases were already green, which is D4 holding) | 24/24 | — | +| `TranslationPipeline.translatedPaths` | 3 failed | 206/206 | — | +| handler wiring | 4 failed | 28/28 | — | +| integration — staleness refresh | see below | 7/7 | 3 run, 2 killed, **1 survived and exposed two coverage holes** | +| seen-but-not-ours marker | 5/5 red | green | 1 run, 1 killed | +| provenance reads made best-effort | 2 red | green | — | + +*(The D6 and D6a rows that stood here were removed with those decisions — they described work that was +reverted, and leaving them listed as green was the review's "stale documentation" finding.)* + +### Findings the mutations produced, and what was done + +**1 — a dead guard in the parser.** `!Array.isArray(value)` could never run: a `startsWith("{")` +prefilter rejected arrays first. The prefilter was removed so the structural guard does the work it +claims to; the mutation now kills a case. + +**2 — the collector named leaves from data that cannot name them.** `DataReconciler` deliberately +strips a per-locale row's `id` (`DataReconciler.ts:99-100`), so the walked `filteredData` has no id to +key an address by and fell back to `#index`. The address is now taken from the **source** element. The +drift guard had not caught this because its fixture fed the untouched document; it now feeds a +reconciled one, and a mutation back to the old behaviour fails it. + +**3 — two integration coverage holes, found by surviving mutations.** "No receipt at all" and "a +receipt that exists but never recorded this leaf" are different branches, and only the first was +covered; a case for the second was added and now kills that mutation. Separately, the integration +fixture **cannot** prove the address is id-keyed, because `items` in the test collections is +non-localized — its rows are shared and keep their ids, so a positional address resolves to the same +leaf. The comment claiming otherwise was corrected; the unit-level drift guard is what proves it. + +## Review log + +### 2026-10-03 · sp-review-deep · target=working-diff +- **Vectors:** core(correctness · regression · intent) + contracts-types · tests · performance · conventions · abstractions-solid — 8-way parallel fan-out, then an opus adversarial pass (always-on for deep), then a 2-way focused re-check round (loop-until-dry, capped at 3 rounds). +- **Findings:** 19 raised · 11 dropped (confidence <75, see below) · 9 reported (major: 5, minor: 4) · 2 needs-verification · 0 fixed (fix=off, read-only review). +- **Reopening per round:** 3 → 1 → 1 (did not strictly fall round 2→3; independently confirms convergence alongside the 3-round cap). +- **Resolved:** none — fix=off, all survivors are report-only. +- **Left open (major, unresolved):** + - `staleness.ts` `sameFields` (~L15-17) + `Provenance.service.ts` `record()` (~L99-114): a partial per-leaf receipt (no prior record, only some leaves translated) makes `isRecordStale` report permanently stale — breaks AC9. Both "obvious" one-line fixes are independently blocked by other pinned tests (`staleness.test.ts:63`, `Provenance.service.test.ts:281`/`:299`, `staleness-refresh.int.test.ts:191`) — needs an owner decision on the receipt/`isRecordStale` contract, not a predicate tweak. + - `src/index.ts`: `SourceFingerprint`/`TranslationProvenanceRecord.sourceFingerprint` is public but its only parser (`parseSourceFingerprint`/`serializeSourceFingerprint`) isn't reachable from the package's public entry (`core` isn't in package.json `exports`) — the public type now describes a shape no public API produces. + - `auto-translate-only-changed.int.test.ts:63`: AC3's "skip_existing refreshes a stale leaf" case never configures `strategy: "skip_existing"` (boots with the "overwrite" default) — passes vacuously; no test anywhere exercises the real path. + - `Provenance.service.ts` `record()` (~L104): new unprotected `previousFingerprint()` read before the `swallowOrThrow`-wrapped write — a transient failure after the translation is already saved reports the request as failed. + - `handler.ts:122`: a second, distinct unprotected `previousFingerprint()` read BEFORE translation starts (2x corroborated) — a transient failure here now aborts the whole request before the provider is ever called, where a provenance failure previously never blocked translation. Any fix must make this read best-effort WITHOUT making `record()`'s read best-effort the same way (a swallowed null there would silently erase other leaves' provenance on upsert). +- **Left open (minor, unresolved):** stale Progress-log table rows for withdrawn D6/D6a tests; `SourceFingerprint` missing `@since` tag; orphaned/stale JSDoc on `translateContent.ts`; `getStaleness` now always double-walks the document (document hash + field hashes) even though the document hash only serves legacy receipts. +- **Needs-verification (handoff to owner):** AC5/AC13 — no recorded evidence the 500-leaf round-trip and the three untouched `skip_existing` suites were actually run on Postgres/Mongo (only SQLite-shaped evidence in the artifacts), despite the owner's own constraint that adapter runs aren't reasoned about; and whether the breaking change to the public `TranslationProvenanceRecord` type will ship with a major-bump commit given the repo's commit-type-driven release process. +- **Dropped (confidence <75):** provenance key ignores `sourceLocale` (50); `record()`'s redundant `previousFingerprint` re-fetch as a pure efficiency concern (70 — the same call site was independently confirmed as a correctness/error-propagation issue above, at higher confidence); `ProvenanceStore.interface.ts` `upsert` type permitting `null` against a `required:true` column (45, dormant); `TextChunkExpander.test.ts` hardcoded `idPath: "name"` fixture bug (55, dormant). +- **Pin:** not set — confirmed reopening findings (AC9, AC3, the two unprotected reads) remain unresolved in a read-only (fix=off) run. + +### After the deep review — what was fixed and what was not + +The review raised 19, reported 9. Acted on: + +**The one that mattered — a defect this change introduced, reproduced before it was fixed.** A run that +translated some leaves and deliberately skipped others wrote a **shorter** map, and `sameFields` +requires the key sets to match, so the indicator read "out of date" immediately after a successful run +and could never be cleared: clearing it needed the skipped leaf in the map, which needed translating +it, which nothing would do. Measured: receipt `{"note":"7cddd654af7c2adf"}` for a two-leaf document, +`is_stale: true` with the source unmoved. + +Neither one-line fix was legal — relaxing the key check collides with the pinned "a leaf was +added/removed" cases, and writing a complete map collides with the three cases that stop a run +claiming a leaf it did not translate. The receipt could not express **"seen, not mine"** apart from +**"never seen"**. + +Fixed by giving it a third value: a leaf the run saw and declined is recorded as `null`. A hash means +"translated from this text", `null` means "saw it, left it", absent means "appeared afterwards". +`sameFields` skips `null` entries, so a declined leaf no longer reads as drift; a genuinely new leaf +still does. `sourceChangedAt` reads `null` as `undefined`, so D4's safety rule is untouched. Pinned by +`per-field-receipt.int.test.ts` ("does not leave the indicator stuck on…" and its opposite, "still +lights the indicator when a leaf genuinely appears"), and a mutation back to the old merge fails it. + +**Two unprotected provenance reads this change added**, fixed differently on purpose: +- `handler.ts` — the read before translating is now best-effort. A sidecar that cannot be reached + degrades every leaf to "we do not know", which is the behaviour from before receipts existed, rather + than aborting the request before the provider is called. +- `Provenance.service.record()` — the read moved **inside** the existing guard, so a failure aborts the + write instead of being swallowed. Swallowing it to `null` would make the merge treat a real receipt + as none and mark every untouched leaf as not-ours, erasing what earlier runs recorded. + +**A test that proved nothing.** The case titled "refreshes a stale leaf when the collection asked for +`skip_existing`" booted auto-translate without a strategy, so it ran under the `overwrite` default and +passed identically either way. Moved to `auto-translate-skip-existing.int.test.ts`, which is now the +only place anywhere that boots auto-translate with that strategy actually set. + +**A public type nothing public could produce.** `TranslationProvenanceRecord` describes a parsed +fingerprint, but the only parser lived in `core`, which is not in the package's `exports`. +`parseSourceFingerprint` / `serializeSourceFingerprint` are now exported beside it. + +**Minor:** `@since` added to the new public type; the docblock in `translateContent.ts` reattached to +its function; the progress-log rows for the withdrawn D6/D6a removed. + +### Deferred — validation in core + +Raised by the owner while reading `isRecordedMap`: hand-rolled type guards are hard to read, and the +package already depends on zod — just not in `core`, which carries a stated dependency-free rule +(`core/index.ts:4`, `index.ts:19`). + +**Measured before deciding.** `core` holds 18 hand-rolled guards, but only **two** parse foreign data: +`SourceFingerprint.ts` (the stored column) and `getAutoTranslateConfig.ts` (the host's `custom` bag). +The other sixteen narrow unions that are already typed — which field type this is, whether a node is +Lexical — and a schema does not help there. Several sit on the translation hot path. + +Also measured: `@repo/translator-core` **does not exist**. Nothing outside the plugin imports `core`. +The dependency-free rule is written for an extraction that has not happened, so it is live as intent, +not as a constraint. + +**Decided by the owner:** relax the rule — zod may enter `core`. The argument that it would cost +portability is weak, since zod has no dependencies of its own and runs anywhere; the real cost is +weight. + +**Done:** both guards are zod schemas now. + +```ts +const recordedMap = z.record(z.string().nullable()); // SourceFingerprint.ts +const autoTranslateConfig = z.object({ targets: z.array(z.string()) }); // getAutoTranslateConfig.ts +``` + +Each site carries a `TODO(core-deps)` marker naming the rule that was relaxed and when to revisit — +if the `@repo/translator-core` extraction is ever done. + +Both are **checked, not parsed**: `safeParse(...).success` with the original value returned. For +`getAutoTranslateConfig` that is load-bearing — zod strips keys it does not describe, and the optional +settings (`strategy`, `debounceMs`, `sourceLocale`) are not in the schema, so parsing would silently +drop them. + +The sixteen guards that narrow already-typed unions are untouched, for the reason measured above. + +**Rejected outright: writing a small zod-alike in `core`.** The hard part of zod is not the runtime +checks, which are four lines of `typeof`; it is the type inference. Without it the type is written +twice — once as a schema, once by hand — and the two drift, which is the defect the exercise set out to +remove. A hand-rolled validator is a library, and a library is forever, in a plugin whose job is +translating content. + +### Not fixed, with the reason + +**The double document walk in `makeCurrentFingerprint`** (review, minor, confidence 75). Reading +staleness computes both the document-wide hash and the per-field map, and the document hash only serves +legacy receipts. Removing the second walk means either making the field lazy inside a plain data type, +or restructuring the three hashing entry points so one walk feeds both — and that puts a second copy of +the hashing rule in play, which is exactly the drift the guard in `driftGuard.test.ts` exists to +prevent. One extra walk on an admin read is the cheaper side of that trade. Recorded so the next +reviewer does not re-raise it as an oversight. + +**Adapter evidence** was the review's `needs-verification` handoff: the runs were made and the numbers +reported in conversation, but nothing in this file recorded them. Now they are — see the run below. + +### Comment audit — 2026-10-05 + +153 comment blocks across the whole working diff, judged in three slices by independent +fresh-eyes passes (core domain 34 · pipeline and server 51 · tests 68). Applied: 78 deletes, +34 comment rewrites, 11 code rewrites, 9 missing notes added; 18 kept with a named reason. + +Seven notes were symptoms of code that did not state its own constraint, and the code changed +instead: + +- `SourceFingerprint.parseSourceFingerprint` — `asFieldMap` extracted, so the `catch` branch no + longer needs a sentence explaining where control lands. +- `staleness.ts` — `matchesClaim` extracted. A `DECLINED` entry reading as "present, nothing to + compare" is what stops a deliberately skipped leaf showing as permanent drift; that rule was an + anonymous `||` branch. The file is 90 lines, from 123. +- `ProvenanceService.readFingerprint` → `readFingerprintOrThrow` (private, two call sites). Two + docblocks circling "which read may see a failure" were deleted; the name carries it. +- `provenanceIo` — the deliberate omission of `scope` is now `OUTSIDE_THE_CALLERS_TRANSACTION` + rather than an undocumented five-key destructure against a six-key target. +- Test-side: named `{ publish }` instead of a positional boolean; `MIN_ROUND_TRIP_BYTES` derived + from `LEAVES`; `SHA256_HEX_LENGTH` named in the four places where the 64 is load-bearing. + +**Found while verifying, and fixed:** `apps/dev` `check-types` had two `TS2352` errors and oxlint +one unused binding, both in files this task adds. Proved pre-existing to the audit by re-running +against the staged version. The package gates were being run without the app's. + +### Rejected verdicts — do not re-raise + +- **`TranslationPipeline.ts` `(ctx.fieldChunks ?? []).map(...)`** was reported as a silent + substitution that makes a run claim nothing. It is unreachable: `stages` is a fixed list, the + collector always populates `fieldChunks`, and an empty result returns `null` inside the loop. The + report also claimed the sibling guard throws; it returns `null`. +- **`lastTranslatedFrom`'s `return read ?? null`** was reported as collapsing "the read failed" into + "there is no receipt". That collapse is the documented contract — both degrade every leaf to + unknown, and widening the return hands callers a distinction they cannot act on. +- **Both `TODO(core-deps)` marks** were deleted by two independent passes, partly on a dead + `core/index.ts:4` citation. The citation is repaired and the dependency rule is back in the + barrel header. The marks stay, at two lines each, by owner's instruction. +- **Ten blocks reported as unjudgeable** at `server/modules/provenance/index.ts:26-112` are + phantoms: the candidate list was cut while that barrel still carried annotated re-exports, which + the re-export trim had already removed. Nothing is unjudged. + +### Deferred + +`filteredData` → `reconciledData` (nine references) would remove a comment that exists only to +correct the name, but it reaches `DataReconciler.stage.ts`, outside this diff. The eight section +banners in `src/index.ts` are pre-existing as a set; cutting one leaves the file inconsistent. + +Gates after the audit: 1929 unit · check-types clean in both package and app · oxlint 55/0 in the +package and 0 in the app · integration sqlite 178, postgres 184, mongo 177, re-run after a rebuild +with the core edits and identical to the pre-audit run. + +### The fingerprint is internal — decided 2026-10-05 + +The task briefly made `SourceFingerprint` and `parseSourceFingerprint` public, to close a review +finding that the public `TranslationProvenanceRecord` had come to describe a shape no public API +produced. That was fixing a consequence. The owner's call reversed it at the cause: + +**How the plugin decides a locale is out of date is its own business.** The truncation rule, the +address grammar and the three-valued leaf record are mechanism, not contract. A consumer reading the +sidecar wants *which document, which locale, translated when* — whether it is stale is answered by +the staleness endpoint. + +So `TranslationProvenanceRecord` — public since 0.7.0 — now carries only the five consumer-facing +fields, and the two fingerprint columns moved to `ProvenanceReceipt extends +TranslationProvenanceRecord`, which stays internal. Nothing new is exported from `src/index.ts`; the +`@since 0.15.0` marks came off `SourceFingerprint.ts`, which is no longer public API. + +**Public surface audit that produced this.** All 58 symbols exported from `src/index.ts` were checked +against two criteria: documented in README, or called from an app in this repo. Six were neither. +Four of those are reachable from signatures a consumer must name and stay — +`OpenAIProviderConfig` (parameter of `createOpenAIProvider`), `DryRunConfig` (its `dryRun` field), +`RequestScope` (second parameter of `TaskRunnerProvider` and `TaskRunner.enqueue`) and `Requester` +(a field of it). The remaining two were the ones this task added. No other internals leak. + +Separate debt found and not fixed here: `RequestScope`/`Requester` carry `@since 0.14.0` in code but +have no `Since v0.14.0` note in README, which the package convention requires. + +**Release consequence.** Removing two fields from a published type is breaking in substance. The +owner's decision is to ship it as a minor while the package is in beta, so the commit must be +`feat:` — the release config has no major remapping for 0.x, and a `feat!:` or a `BREAKING CHANGE:` +footer would compute 1.0.0 rather than 0.15.0. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-05-provenance-service-without-payload.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-05-provenance-service-without-payload.task.md new file mode 100644 index 000000000..0e84f02c8 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-05-provenance-service-without-payload.task.md @@ -0,0 +1,190 @@ +# Task contract — `ProvenanceService` without the Payload god type + +**Risk: LOW.** One class, one wiring site, one test file. No behaviour change, and the module it +lives in already has 72 passing tests over it. + +**Separate from** the per-field fingerprints work sitting in the same tree +(`docs/plans/2026-10-02-per-field-fingerprints.task.md`). That contract's D1–D7, `## Human choices` +and `## Review log` are settled and not reopened here. This earns its own commit. + +--- + +## Phase 1 — what was found + +**What `payload` actually buys the service: two things.** + +| use | sites | +|---|---| +| `this.payload.logger.error({...})` | 4 — `:92`, `:139`, `:166`, `:206` | +| `fetchSourceDocument({ payload, ... })` | 1 — `:268`, inside `makeCurrentFingerprint` | + +Nothing else. The whole god type is carried for a logger and one read. + +**The cost is visible in the tests.** `Provenance.service.test.ts:30-35`: + +```ts +function makePayload(findByID): Payload { + return { findByID: vi.fn(findByID), logger: { error: vi.fn() } } as unknown as Payload; +} +``` + +A double cast to forge a type with a hundred members, for two of them. + +**Precedent: the project already has the answer, in this very module.** The package's `CLAUDE.md` +states it as a rule — *"Functions/classes must not depend on Payload's god types … Define a narrow +structural interface with only the fields you touch — the real Payload type is assignable to it, so it +plugs in with no adapter and tests pass a tiny literal."* — and `Provenance.shapes.ts` already holds +exactly such slices (`ManagedCollectionEntry`, `ManagedCollectionsConfig`). Four `.shapes.ts` files +exist across the package. This task applies an established idiom rather than inventing one. + +**Measured, not assumed: `payload.logger` is structurally assignable to a narrow slice.** Probed with +a throwaway file and `tsgo`: + +```ts +type NarrowLogger = { error(details: Record): void }; +export function probe(payload: Payload): NarrowLogger { return payload.logger; } // 0 errors +``` + +So the factory passes `payload.logger` straight through — no adapter, no wrapper. + +**Blast radius: four files.** `Provenance.service.ts`, `Provenance.shapes.ts`, `Provenance.wiring.ts`, +and the service's test file. `ProvenanceServiceFactory` keeps its `(payload, scope)` signature — it is +the adapter — so its consumers (`translate-document/handler.ts`, both staleness handlers) are +untouched. + +--- + +## Design decisions + +**D1 — two dependencies of different shapes, deliberately.** + +- **The logger is a structural slice.** It is an object whose method the service calls, and the live + `payload.logger` satisfies the slice with no adapter (measured above). That is exactly what + `.shapes.ts` is for. +- **The source read is a bound function.** `fetchSourceDocument` requires a real `Payload`, and + narrowing *it* is out of scope for this task — so the service cannot hold a slice and call it. It + receives the read already bound to a payload instead. + +Rejected: one `deps` bag holding both. The two have nothing to do with each other, and the +constructor already takes its collaborators positionally in this codebase's style. + +Rejected: giving the service a narrow `{ findByID }` slice and letting it call `fetchSourceDocument` +itself. It would be the tidier symmetry, but it requires narrowing `fetchSourceDocument`, which this +task is explicitly forbidden to touch — that function is the one place two earlier decisions live +(`enforcedAtTheRead`, `{ draft: true, fallbackLocale: false }`) and it has three callers. + +**D2 — the types live in `Provenance.shapes.ts`.** The module's existing shapes file, holding the same +kind of slice, with a docblock that already states the principle. Prefer editing an existing file. + +**D4 — corrected at the start of Phase 3: `CollectionSlug` stays, and the factory type moves.** + +The criterion first read "imports no type from `payload`". Two things make that the wrong target, and +saying so now is cheaper than discovering it at grading: + +- **`CollectionSlug` is not a god type.** It is a union of the host's collection slugs — effectively a + checked `string`. Replacing it with `string` would lose call-site safety and buy nothing; the thing + the owner objects to is the `Payload` instance, not the package. +- **`ProvenanceServiceFactory` legitimately names `Payload`** — it *is* the adapter boundary, and its + consumers pass `req.payload`. It does not belong in a file that is meant to be Payload-free, so it + moves to `Provenance.wiring.ts`, which is where the factory is actually produced. All five consumers + import it through the module barrel, so the move is invisible to them. + +So the criterion is now about the **class**, not the file's import list. + +**D3 — the bound read keeps today's arguments exactly, including what it omits.** +The current call passes `{ payload, collection, id, locale, user }` and **not** `scope`, while every +store write does thread `scope`. That asymmetry is pre-existing; this refactor preserves it rather +than silently changing behaviour. See Risk Notes. + +### Placement + +| piece | lands in | why | +|---|---|---| +| `ProvenanceLogger` slice | `Provenance.shapes.ts` | the module's existing narrow-slice file | +| `SourceDocumentReader` function type | `Provenance.shapes.ts` | same file; it is the other half of the same seam | +| binding `payload.logger` and `fetchSourceDocument` | `Provenance.wiring.ts:58` | the factory is already the Payload-aware adapter | + +### New surface + +Two types. Callers: `Provenance.service.ts` (consumer), `Provenance.wiring.ts` (producer), and the +service's tests. Two production call sites, which clears the bar. + +### Written contract + +One, inherited rather than new: the bound read answers `null` for *not available to this caller*, and +deliberately does not distinguish refused from absent — `fetchSourceDocument`'s own documented +contract. The type's docblock must say so, or a reader of the service will take `null` for "no such +document". + +### Escalate? + +**No.** One module, no new pattern, no dependency, no data-model change, and the placement is dictated +by an existing convention in the same directory. + +--- + +## Acceptance criteria + +| # | Criterion | Declared check | Pre-flight | +|---|---|---|---| +| 1 | The `ProvenanceService` class does not touch the `Payload` god type — not in a field, a constructor parameter, or a method body | `grep -c "\bPayload\b"` in the file → 0; `check-types` clean | **fails now** | +| 2 | The service's tests build it with plain literals, with no `as unknown as Payload` | `grep -c "as unknown as Payload"` → 0 in that file | **fails now** (1 cast) | +| 3 | Behaviour is unchanged: every existing case in the provenance module still passes, with only construction updated and no expectation rewritten | `bunx vitest run src/server/modules/provenance` → 72; plus a read of the test diff | **passes now** (72) | +| 4 | The wiring hands over `payload.logger` directly, with no adapter object | reading `Provenance.wiring.ts`; `check-types` clean | fails now (no such call) | +| 5 | The bound read passes exactly what the call passes today — `collection`, `id`, `locale`, `user`, and **no** `scope` | reading the bound call against `Provenance.service.ts:267-273` as it stands | fails now (not bound yet) | +| 6 | Package baselines hold: unit **1929**, `check-types` clean, `oxlint` **55 warnings / 0 errors** | the three commands, output quoted | **passes now** | +| 7 | Integration unchanged on all three adapters: SQLite **178**, PostgreSQL **184**, MongoDB **177** | `bun run build`, then the three suites | **passes now** | + +**Outcomes are three: met · not met · not verified.** A criterion whose declared check could not be +run is recorded `not-verified`, never `met` via a weaker proxy. + +--- + +## Risk Notes + +- **The `scope` asymmetry (D3).** The staleness recompute reads the source *without* the caller's + transaction, while provenance writes thread it. Pre-existing, preserved here, and now easier to see + because the read becomes an explicit bound dependency. Worth its own look later; changing it in a + refactor that claims no behaviour change would be dishonest. +- **The logger slice must stay as narrow as the calls.** All four sites pass a single object + (`{ err, collection, documentId?, targetLocale?, sourceLocale?, msg }`). If the slice grows a second + method nobody calls, it stops being the thing this task is for. +- **A refactor with no behaviour change is graded by its tests.** The guard is that no expectation in + the existing 72 may change — only how the service is constructed. A diff that rewrites assertions is + the failure mode here, and criterion 3 names it. + +## Human choices + +- **`fetchSourceDocument` is not removed, and not narrowed.** The owner asked whether to drop it; the + answer is no. Three callers, and it holds two settled decisions (`enforcedAtTheRead` from the + read-rules work; `{ draft: true, fallbackLocale: false }` from "take the source from the locale's own + current version"). Its own docblock states why it must be the single read: *"or the fingerprints they + compare drift apart."* Inlining it would copy both decisions into three places and remove the guard + against exactly the drift the previous task spent a day chasing. +- **The service is not moved to `core`.** This refactor opens that seam — once Payload is gone, the + class is portable, and the rest of the fingerprint policy (`staleness.ts`, `SourceFingerprint.ts`) + already lives there. Deliberately out of scope; a separate conversation. +- **The 35 dead re-exports in `core/index.ts` stay.** They are the surface of the unfinished + `@repo/translator-core` extraction, not carelessness. Untouched by decision. + +## Review log + +(appended by review passes) + +### Comment audit — 2026-10-05 + +This work's eight files were judged in the pipeline-and-server slice. The audit's payload here was +structural, not prose: + +- `ProvenanceService.readFingerprint` → `readFingerprintOrThrow`. The class carried three separate + docblocks circling one rule — that only `record` may see a read failure rather than a `null`. Two + are deleted; the name states it. +- `provenanceIo` now passes `scope: OUTSIDE_THE_CALLERS_TRANSACTION`. The omission of `scope` was + deliberate and preserved from before this refactor, but a five-key destructure against a six-key + target is the shape a reader silently "fixes". +- `Provenance.shapes.ts` lost two restatements of the structural-typing convention; it is stated + once, at the top of the file, and the package `CLAUDE.md` already rules on it. The paragraph + defending `CollectionSlug` was an answer to a reviewer and is gone. + +Rejected: widening `lastTranslatedFrom`'s return so the swallow's `undefined` stays distinct from +`null` — see the rejected-verdicts note in the per-field-fingerprints contract. diff --git a/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-02-red-runs.txt b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-02-red-runs.txt new file mode 100644 index 000000000..bd75eb489 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-02-red-runs.txt @@ -0,0 +1,19 @@ + × computeFieldFingerprints > emits one entry per translatable leaf, keyed by its id-based address 3ms + × computeFieldFingerprints > leaves out what the projection leaves out 0ms + × computeFieldFingerprints > gives a leaf the same hash for the same text, and a different one when the text changes 0ms + × computeFieldFingerprints > does not move a sibling's hash when an element is inserted ahead of it 0ms + × computeFieldFingerprints > drops the address of a leaf that is no longer in the document 0ms + × computeFieldFingerprints > returns an empty map for a document with nothing translatable 0ms + Test Files 1 failed (1) + Tests 6 failed (6) + Test Files 1 failed (1) + Tests 23 failed (23) + Tests 17 failed (17) + Tests 8 failed | 11 passed (19) + Tests 7 failed | 8 passed (15) + Tests 1 failed | 3 passed (4) + Tests 1 failed | 15 passed (16) + Tests 3 failed | 23 passed (26) + Tests 4 failed | 24 passed (28) + Tests 1 failed | 14 passed (15) + Tests 1 failed | 5 passed (6) 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 c75230673..24919d175 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 @@ -68,8 +68,8 @@ export function TranslationStatusList({ rows, collection, id }: TranslationStatu }); }; - // Re-translate one locale in place: a fresh source→target job (overwrite). Reuses the same queue - // endpoint as the form; onSuccess invalidation refreshes this list. + // `skip_existing`, not `overwrite`: this button sits under the "out of date" badge, so switching it + // back would silently discard a reviewer's edit to a leaf whose source never moved. const reTranslate = (row: TranslationStatusRow) => withToast( () => @@ -78,7 +78,7 @@ export function TranslationStatusList({ rows, collection, id }: TranslationStatu target_lng: row.targetLocale, collection_slug: collection, collection_id: [id], - strategy: "overwrite", + strategy: "skip_existing", }), "Failed to queue translation" ); 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 7f91dc40f..e570a56c2 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 @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { isObject } from "../../kernel/utils/isObject.js"; import type { AutoTranslateConfig } from "./types.js"; @@ -10,10 +12,13 @@ import { AUTO_TRANSLATE_CUSTOM_KEY } from "./types.js"; * 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`. */ function isAutoTranslateConfig(value: unknown): value is AutoTranslateConfig { - if (!isObject(value)) return false; - const { targets } = value; - return Array.isArray(targets) && targets.every((target) => typeof target === "string"); + return autoTranslateConfig.safeParse(value).success; } /** diff --git a/packages/payload-plugin-translator/src/core/domain/content-projection/computeFieldFingerprints.test.ts b/packages/payload-plugin-translator/src/core/domain/content-projection/computeFieldFingerprints.test.ts new file mode 100644 index 000000000..88152bbca --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/content-projection/computeFieldFingerprints.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import type { FieldLike } from "../../kernel/field-traversal/types.js"; + +import { computeFieldFingerprints } from "./computeFieldFingerprints.js"; + +const schema: FieldLike[] = [ + { name: "title", type: "text", localized: true }, + { name: "slug", type: "text" }, // not localized + { + name: "items", + type: "array", + fields: [{ name: "label", type: "text", localized: true }], + }, +]; + +const doc = { + title: "The Title", + slug: "the-title", + items: [ + { id: "a1", label: "First" }, + { id: "b2", label: "Second" }, + ], +}; + +describe("computeFieldFingerprints", () => { + it("emits one entry per translatable leaf, keyed by its id-based address", () => { + expect(Object.keys(computeFieldFingerprints(doc, schema)).sort()).toEqual([ + "items.a1.label", + "items.b2.label", + "title", + ]); + }); + + it("leaves out what the projection leaves out", () => { + expect(computeFieldFingerprints(doc, schema)).not.toHaveProperty("slug"); + }); + + it("gives a leaf the same hash for the same text, and a different one when the text changes", () => { + const before = computeFieldFingerprints(doc, schema); + const again = computeFieldFingerprints({ ...doc }, schema); + const edited = computeFieldFingerprints({ ...doc, title: "Another Title" }, schema); + + expect(again.title).toBe(before.title); + expect(edited.title).not.toBe(before.title); + }); + + it("does not move a sibling's hash when an element is inserted ahead of it", () => { + const before = computeFieldFingerprints(doc, schema); + const inserted = computeFieldFingerprints( + { ...doc, items: [{ id: "z9", label: "Inserted" }, ...doc.items] }, + schema + ); + + expect(inserted["items.a1.label"]).toBe(before["items.a1.label"]); + expect(inserted["items.b2.label"]).toBe(before["items.b2.label"]); + expect(inserted["items.z9.label"]).toBeDefined(); + }); + + it("drops the address of a leaf that is no longer in the document", () => { + const withoutItems = computeFieldFingerprints({ title: "The Title" }, schema); + expect(withoutItems).toEqual({ title: expect.any(String) }); + }); + + it("returns an empty map for a document with nothing translatable", () => { + expect(computeFieldFingerprints({ slug: "only-this" }, schema)).toEqual({}); + }); +}); 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 new file mode 100644 index 000000000..a91c3c6f1 --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/content-projection/computeFieldFingerprints.ts @@ -0,0 +1,25 @@ +import { createHash } from "node:crypto"; + +import type { FieldLike } from "../../kernel/field-traversal/types.js"; + +import { projectTranslatableContent } from "./contentProjector.js"; + +/** Hash of each translatable leaf's source text, keyed by the leaf's `IdPath`. */ +export type FieldFingerprints = Readonly>; + +/** Truncated sha256: a change detector between two states of one leaf, never a security token. */ +const HASH_LENGTH = 16; + +const hashText = (text: string): string => + createHash("sha256").update(text).digest("hex").slice(0, HASH_LENGTH); + +export function computeFieldFingerprints( + doc: Record, + schema: FieldLike[] +): FieldFingerprints { + const hashes: Record = {}; + for (const entry of projectTranslatableContent(doc, schema)) { + hashes[entry.idPath] = hashText(entry.text); + } + return hashes; +} 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 50491d644..bc5b6d066 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,3 +1,5 @@ +import type { SourceFingerprint } from "./SourceFingerprint.js"; + /** * Translation provenance — the durable record of *what source state a target locale was translated * from* (design: `docs/plans/2026-06-26-translation-provenance-and-lifecycle-design.md`). @@ -8,11 +10,15 @@ */ /** - * One provenance receipt: a single `(collection, document, target locale)` translation. + * One provenance receipt: a single `(collection, document, target locale)` translation, as a consumer + * reads it from the sidecar collection. * * `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. + * * @since 0.7.0 */ export interface TranslationProvenanceRecord { @@ -24,20 +30,14 @@ export interface TranslationProvenanceRecord { targetLocale: string; /** Locale the translation was derived from. */ sourceLocale: string; - /** - * Fingerprint of the source content at translation time — - * `fingerprint(projectTranslatableContent(sourceDoc, schema))`. Staleness (later, in #50) is - * `currentSourceFingerprint !== sourceFingerprint`. - */ - sourceFingerprint: string; - /** ISO-8601 timestamp of the last successful translation. */ translatedAt: string; - /** - * The source fingerprint an editor acknowledged as "stale but leave it" (#50's dismissable - * indicator). `null` until dismissed. Written by #50 — carried here now so no later migration is - * needed. - */ - dismissedFingerprint: string | null; +} + +/** 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; } /** @@ -83,14 +83,14 @@ export interface ProvenanceStore { * * @param record - The provenance receipt to persist. */ - upsert(record: TranslationProvenanceRecord): Promise; + upsert(record: ProvenanceReceipt): Promise; /** * Look up the record for a key. * * @param key - The `(collectionSlug, documentId, targetLocale)` identity. * @returns The stored record, or `null` if none exists. */ - find(key: ProvenanceKey): Promise; + find(key: ProvenanceKey): Promise; /** * List every record for a document — one per translated target locale. Backs #50's per-locale * staleness read for a single document panel. @@ -99,19 +99,13 @@ export interface ProvenanceStore { * @param documentId - Stringified id of the translated document. * @returns All provenance receipts for the document (empty when none exist). */ - findByDocument( - collectionSlug: string, - documentId: string - ): Promise; + 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. - * - * @param key - The `(collectionSlug, documentId, targetLocale)` identity to dismiss. - * @param dismissedFingerprint - The current source fingerprint being acknowledged. */ - dismiss(key: ProvenanceKey, dismissedFingerprint: string): Promise; + dismiss(key: ProvenanceKey, dismissedFingerprint: SourceFingerprint): 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 new file mode 100644 index 000000000..5a9e7d895 --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { parseSourceFingerprint, serializeSourceFingerprint } from "./SourceFingerprint.js"; + +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", () => { + expect(parseSourceFingerprint('{"title":"3a7f","items.a1.label":"91ce"}')).toEqual({ + kind: "fields", + hashes: { title: "3a7f", "items.a1.label": "91ce" }, + }); + }); + + it("reads a leaf recorded as seen-but-not-ours", () => { + expect(parseSourceFingerprint('{"title":null,"note":"91ce"}')).toEqual({ + kind: "fields", + hashes: { title: null, note: "91ce" }, + }); + }); + + it("reads an empty per-field map as a per-field map, not as absent", () => { + expect(parseSourceFingerprint("{}")).toEqual({ kind: "fields", hashes: {} }); + }); + + // 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)], + ["something shorter than a digest", "abc123"], + ["whitespace", " "], + ["a JSON array", '["title"]'], + ["JSON null", "null"], + ["a JSON number", "42"], + ["a JSON string", '"title"'], + ["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) => { + expect(parseSourceFingerprint(stored)).toBeNull(); + }); + + it("never throws, whatever it is handed", () => { + for (const stored of ["", "{", "}{", "\u0000", "{]", LEGACY.toUpperCase()]) { + expect(() => parseSourceFingerprint(stored)).not.toThrow(); + } + }); +}); + +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: "3a7f" } })).toBe( + '{"title":"3a7f"}' + ); + }); + + it.each([ + ["the document shape", { kind: "document", hash: LEGACY } as const], + [ + "the per-field shape", + { kind: "fields", hashes: { title: "3a7f", "i.a1.l": "91ce" } } as const, + ], + [ + "a map carrying the seen-but-not-ours marker", + { kind: "fields", hashes: { title: null, note: "91ce" } } as const, + ], + ["an empty per-field map", { kind: "fields", hashes: {} } as const], + ])("round-trips %s through the column", (_label, fingerprint) => { + expect(parseSourceFingerprint(serializeSourceFingerprint(fingerprint))).toEqual(fingerprint); + }); + + it("round-trips an address holding the characters the path grammar escapes", () => { + const hashes = { "items.a1\\.weird:block.label": "91ce" }; + expect(parseSourceFingerprint(serializeSourceFingerprint({ kind: "fields", hashes }))).toEqual({ + kind: "fields", + hashes, + }); + }); +}); diff --git a/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.ts b/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.ts new file mode 100644 index 000000000..af93eae0f --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/provenance/SourceFingerprint.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +/** A leaf the run saw and declined to translate: the target text is not ours to describe. */ +export const DECLINED = null; + +/** + * What a receipt records about one leaf: the hash it was translated from, {@link DECLINED}, or — when + * the address is absent altogether — a leaf that appeared after the translation. + */ +export type RecordedFingerprints = Readonly>; + +/** + * What a receipt stores about the source it was translated from. `document` is the single sha256 + * written before per-leaf fingerprints existed: it says the document moved without saying where, so + * no leaf-level question can be answered from it. + */ +export type SourceFingerprint = + | { kind: "document"; hash: string } + | { kind: "fields"; hashes: RecordedFingerprints }; + +// 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 recordedMap = z.record(z.string().nullable()); + +const asFieldMap = (stored: string): RecordedFingerprints | null => { + try { + const asMap = recordedMap.safeParse(JSON.parse(stored)); + return asMap.success ? asMap.data : null; + } catch { + return null; + } +}; + +/** + * Read a stored fingerprint. Anything that is not a per-field map is taken as the legacy document + * digest — a corrupt value is deliberately not an error, it simply never matches. `null` only when + * the column held nothing. + */ +export function parseSourceFingerprint( + stored: string | null | undefined +): SourceFingerprint | null { + if (typeof stored !== "string" || stored.length === 0) return null; + + const hashes = asFieldMap(stored); + return hashes === null ? { kind: "document", hash: stored } : { kind: "fields", hashes }; +} + +export function serializeSourceFingerprint(fingerprint: SourceFingerprint): string { + return fingerprint.kind === "document" ? fingerprint.hash : JSON.stringify(fingerprint.hashes); +} 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 9e67d26ff..d2d9c7a3c 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/index.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/index.ts @@ -1,8 +1,14 @@ -// Provenance contracts (port + record types) only — payload-free. The Payload-backed store lives -// in the plugin (src/server/modules/provenance), outside the core. export type { ProvenanceKey, + ProvenanceReceipt, ProvenanceStore, TranslationProvenanceRecord, } from "./ProvenanceStore.interface.js"; -export { isRecordStale } from "./staleness.js"; +export type { CurrentFingerprint } from "./staleness.js"; +export { changedLeaves, isRecordStale, recordsEachLeaf } from "./staleness.js"; +export type { SourceFingerprint } from "./SourceFingerprint.js"; +export { + DECLINED, + parseSourceFingerprint, + serializeSourceFingerprint, +} from "./SourceFingerprint.js"; 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 628fa9b4b..46817a7c9 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,34 +1,195 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; -import type { TranslationProvenanceRecord } from "./ProvenanceStore.interface.js"; -import { isRecordStale } from "./staleness.js"; +import type { ProvenanceReceipt } from "./ProvenanceStore.interface.js"; +import type { CurrentFingerprint } from "./staleness.js"; +import { changedLeaves, isRecordStale, recordsEachLeaf, leafSourceChanged } from "./staleness.js"; -const base: TranslationProvenanceRecord = { +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 record = (over: Partial = {}): ProvenanceReceipt => ({ collectionSlug: "posts", documentId: "1", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: "fp-original", + sourceFingerprint: { kind: "document", hash: HASH_A }, translatedAt: "2026-07-07T00:00:00.000Z", dismissedFingerprint: null, -}; + ...over, +}); + +const current = (document: string, fields: Record = {}): CurrentFingerprint => ({ + document, + fields, +}); -describe("isRecordStale", () => { +describe("isRecordStale — a receipt written before per-field fingerprints", () => { it("is not stale when the current fingerprint matches the recorded source fingerprint", () => { - expect(isRecordStale(base, "fp-original")).toBe(false); + expect(isRecordStale(record(), current(HASH_A))).toBe(false); }); it("is stale when the source drifted and nothing was dismissed", () => { - expect(isRecordStale(base, "fp-changed")).toBe(true); + expect(isRecordStale(record(), current(HASH_B))).toBe(true); }); it("is not stale when the current drift equals the dismissed fingerprint", () => { - const dismissed = { ...base, dismissedFingerprint: "fp-changed" }; - expect(isRecordStale(dismissed, "fp-changed")).toBe(false); + 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 = { ...base, dismissedFingerprint: "fp-changed" }; - expect(isRecordStale(dismissed, "fp-changed-again")).toBe(true); + 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 = { kind: "fields", hashes: { title: "1111", body: "2222" } } as const; + + 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" }) + ) + ).toBe(false); + }); + + it("is stale when one leaf's source moved", () => { + expect( + isRecordStale( + record({ sourceFingerprint: stored }), + current(HASH_B, { title: "1111", body: "9999" }) + ) + ).toBe(true); + }); + + it("is stale when a leaf was added", () => { + expect( + isRecordStale( + record({ sourceFingerprint: stored }), + current(HASH_B, { 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); + }); + + it("hides again once that exact drift is dismissed", () => { + const drifted = { title: "1111", body: "9999" }; + const dismissed = record({ + sourceFingerprint: stored, + dismissedFingerprint: { kind: "fields", hashes: drifted }, + }); + expect(isRecordStale(dismissed, current(HASH_B, drifted))).toBe(false); + }); + + // The defect this marker exists to fix: a run that translated some leaves and deliberately skipped + // others used to write a SHORTER map, which read as "the document changed" forever — and the skipped + // leaf could never be added, because adding it required translating it. + it("is not stale when a leaf it deliberately skipped is recorded as not ours", () => { + const withMarker = { kind: "fields", hashes: { title: null, body: "2222" } } as const; + expect( + isRecordStale( + record({ sourceFingerprint: withMarker }), + current(HASH_B, { title: "1111", body: "2222" }) + ) + ).toBe(false); + }); + + it("is still stale when a leaf appeared that the receipt never saw at all", () => { + const withMarker = { kind: "fields", hashes: { title: null, body: "2222" } } as const; + expect( + isRecordStale( + record({ sourceFingerprint: withMarker }), + current(HASH_B, { 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); + }); +}); + +describe("leafSourceChanged", () => { + const recorded = { title: "1111", body: "2222", seen: null } as const; + + it("says a leaf changed when its stored hash differs from now", () => { + expect(leafSourceChanged(recorded, { title: "1111", body: "9999" }, "body")).toBe(true); + }); + + it("says a leaf did not change when its stored hash still matches", () => { + expect(leafSourceChanged(recorded, { title: "1111", body: "2222" }, "body")).toBe(false); + }); + + it("knows nothing about a leaf the receipt never recorded", () => { + expect(leafSourceChanged(recorded, { title: "1111", sub: "3333" }, "sub")).toBeUndefined(); + }); + + it("knows nothing about a leaf recorded as seen-but-not-ours", () => { + expect(leafSourceChanged(recorded, { title: "1111", seen: "3333" }, "seen")).toBeUndefined(); + }); + + it("says a leaf changed when it is recorded but absent from the document now", () => { + expect(leafSourceChanged(recorded, { title: "1111" }, "body")).toBe(true); + }); +}); + +describe("recordsEachLeaf", () => { + it("accepts a receipt that claims leaf by leaf", () => { + expect(recordsEachLeaf({ kind: "fields", hashes: { 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 = { kind: "fields", hashes: { title: "1111", body: "2222", seen: null } } as const; + + it("answers only for the leaves it can answer for", () => { + expect( + changedLeaves(stored, { title: "1111", body: "9999", seen: "3333", fresh: "4444" }) + ).toEqual({ title: false, body: true }); + }); + + it("is empty when there is no receipt", () => { + expect(changedLeaves(null, { title: "1111" })).toEqual({}); + }); + + it("is empty for a receipt written before per-field fingerprints", () => { + expect(changedLeaves({ kind: "document", hash: HASH_A }, { title: "1111" })).toEqual({}); + }); + + it("says a recorded leaf changed when the document no longer has it", () => { + expect(changedLeaves(stored, { title: "1111" })).toEqual({ title: false, body: true }); + }); + + it("agrees with leafSourceChanged leaf for leaf", () => { + const now = { title: "1111", body: "9999", seen: "3333", fresh: "4444" }; + const map = changedLeaves(stored, now); + for (const address of ["title", "body", "seen", "fresh"]) { + expect(map[address], address).toBe(leafSourceChanged(stored.hashes, now, address)); + } }); }); 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 295a0a1a5..c65e34b24 100644 --- a/packages/payload-plugin-translator/src/core/domain/provenance/staleness.ts +++ b/packages/payload-plugin-translator/src/core/domain/provenance/staleness.ts @@ -1,28 +1,85 @@ -import type { TranslationProvenanceRecord } from "./ProvenanceStore.interface.js"; +import type { FieldFingerprints } from "../content-projection/computeFieldFingerprints.js"; + +import type { ProvenanceReceipt } from "./ProvenanceStore.interface.js"; +import type { RecordedFingerprints, SourceFingerprint } from "./SourceFingerprint.js"; + +/** Per-leaf drift. An absent address means nothing could be determined — never "unchanged" ({@link leafSourceChanged}). */ +export type ChangedLeaves = Readonly>; + +export type CurrentFingerprint = { + document: string; + fields: FieldFingerprints; +}; + +/** The hash a receipt entry claims, or `undefined` when it claims none. Positive test on purpose: a stored value neither reader understands must claim nothing, never drift. */ +const claimedHash = (recorded: string | null | undefined): string | undefined => + typeof recorded === "string" ? recorded : undefined; + +const sameAddresses = (stored: RecordedFingerprints, current: FieldFingerprints): boolean => + Object.keys(stored).length === Object.keys(current).length && + Object.keys(stored).every((address) => address in current); + +const matchesClaim = ( + recorded: string | null | undefined, + current: string | undefined +): boolean => { + const claimed = claimedHash(recorded); + return claimed === undefined || claimed === current; +}; + +const sameFields = (stored: RecordedFingerprints, current: FieldFingerprints): boolean => + sameAddresses(stored, current) && + Object.entries(stored).every(([address, recorded]) => matchesClaim(recorded, current[address])); + +const describesCurrentSource = ( + fingerprint: SourceFingerprint | null, + current: CurrentFingerprint +): boolean => { + if (fingerprint === null) return false; + return fingerprint.kind === "document" + ? fingerprint.hash === current.document + : sameFields(fingerprint.hashes, current.fields); +}; /** - * Whether a translated locale is out of date relative to its source — the #50 rule, in one place. - * - * A record is stale when the current source fingerprint differs from the one the translation was - * derived from (`record.sourceFingerprint`) **and** the editor has not acknowledged that exact drift - * (`record.dismissedFingerprint`). Dismissing sets `dismissedFingerprint` to the current fingerprint, - * so the indicator hides until the source changes again (a new fingerprint no longer matches the - * dismissed one). `dismissedFingerprint` is `null` until the first dismiss, in which case the check - * reduces to a plain source-drift comparison. - * - * Pure and payload-free so the staleness contract is testable without a database; the server handler - * supplies `currentFingerprint` via {@link computeSourceFingerprint} on the live source document. - * - * @param record - The stored provenance receipt for one `(collection, document, targetLocale)`. - * @param currentFingerprint - `computeSourceFingerprint` of the source document right now. - * @returns `true` when the target locale is out of date and not dismissed. + * The #50 staleness rule in one place: out of date unless the receipt — or the editor's dismissal — + * describes the source as it stands now. Dismissing stores the current fingerprint, so the flag + * returns only when the source moves again. */ -export function isRecordStale( - record: TranslationProvenanceRecord, - currentFingerprint: string -): boolean { +export function isRecordStale(record: ProvenanceReceipt, current: CurrentFingerprint): boolean { return ( - currentFingerprint !== record.sourceFingerprint && - currentFingerprint !== record.dismissedFingerprint + !describesCurrentSource(record.sourceFingerprint, current) && + !describesCurrentSource(record.dismissedFingerprint, current) ); } + +export const recordsEachLeaf = ( + fingerprint: SourceFingerprint | null +): fingerprint is { kind: "fields"; hashes: RecordedFingerprints } => + fingerprint?.kind === "fields"; + +/** `undefined` means *cannot tell* — callers must claim nothing; answering `true` on a guess overwrites a translation. */ +export function leafSourceChanged( + recorded: RecordedFingerprints, + currentFields: FieldFingerprints, + address: string +): boolean | undefined { + const translatedFrom = claimedHash(recorded[address]); + if (translatedFrom === undefined) return undefined; + return translatedFrom !== currentFields[address]; +} + +export function changedLeaves( + stored: SourceFingerprint | null, + currentFields: FieldFingerprints +): ChangedLeaves { + if (!recordsEachLeaf(stored)) return {}; + + const addressesTheReceiptSaw = Object.keys(stored.hashes); + const answers: Record = {}; + for (const address of addressesTheReceiptSaw) { + const changed = leafSourceChanged(stored.hashes, currentFields, address); + if (changed !== undefined) answers[address] = changed; + } + return answers; +} diff --git a/packages/payload-plugin-translator/src/core/index.ts b/packages/payload-plugin-translator/src/core/index.ts index 9dfd9ee01..1a920cf43 100644 --- a/packages/payload-plugin-translator/src/core/index.ts +++ b/packages/payload-plugin-translator/src/core/index.ts @@ -1,8 +1,3 @@ -// Framework-agnostic translator core. No payload / @payloadcms / next / react. -// The plugin (adapter) re-exports the public bits from here. - -// Translation provider PORT (contract) only — dependency-free. The built-in OpenAI -// implementation lives in the plugin src/providers (outside core) so this barrel never pulls `openai`. export type { TranslationProvider, TranslationInput, @@ -10,18 +5,18 @@ export type { TranslationIndex, } from "./domain/translation-providers/index.js"; -// Translation pipeline export { TranslationPipeline, translateContent } from "./translation-pipeline/index.js"; export type { TranslateContentArgs, TranslationStrategy } from "./translation-pipeline/index.js"; -// Provenance (contracts only — payload-free port + record types) export type { ProvenanceKey, + ProvenanceReceipt, ProvenanceStore, + SourceFingerprint, TranslationProvenanceRecord, } from "./domain/provenance/index.js"; +export { parseSourceFingerprint } from "./domain/provenance/index.js"; -// Content projection export { projectTranslatableContent } from "./domain/content-projection/contentProjector.js"; export type { ProjectionEntry } from "./domain/content-projection/contentProjector.js"; export { fingerprint } from "./domain/content-projection/fingerprinter.js"; @@ -29,7 +24,6 @@ export { computeSourceFingerprint } from "./domain/content-projection/computeSou export { makeIdPath } from "./domain/content-projection/idPath.js"; export type { IdPath, PathSegment } from "./domain/content-projection/idPath.js"; -// Field traversal export { classifyField, findFieldByPath, @@ -41,6 +35,7 @@ export { tabScopes, walkFields, } from "./kernel/field-traversal/index.js"; + export type { ArrayFieldLike, BlockLike, 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 f90150693..fb8b23792 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 @@ -822,4 +822,77 @@ describe("TranslationPipeline", () => { expect(result!.translatedData.sku).toBe("SKU-123"); }); }); + + describe("reporting back which leaves it translated", () => { + const schema: Field[] = [ + { name: "title", type: "text", localized: true }, + { name: "body", type: "text", localized: true }, + { + name: "items", + type: "array", + fields: [{ name: "label", type: "text", localized: true }], + }, + ]; + const sourceData = { + title: "Hello", + body: "World", + items: [{ id: "a1", label: "First" }], + }; + + 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({ + schema, + sourceData, + targetData: {}, + sourceLng: "en", + targetLng: "de", + }); + + expect([...(result?.translatedPaths ?? [])].sort()).toEqual([ + "body", + "items.a1.label", + "title", + ]); + }); + + it("names only the leaves the strategy let through", async () => { + const pipeline = new TranslationPipeline({ + translationProvider: createMockProvider(), + translationStrategy: new SkipExistingStrategy(), + }); + + const result = await pipeline.execute({ + schema, + sourceData, + targetData: { body: "Welt", items: [{ id: "a1", label: "" }] }, + sourceLng: "en", + targetLng: "de", + }); + + expect([...(result?.translatedPaths ?? [])].sort()).toEqual(["items.a1.label", "title"]); + }); + + it("passes the per-leaf answer from the config down to the strategy", async () => { + const pipeline = new TranslationPipeline({ + translationProvider: createMockProvider(), + translationStrategy: new SkipExistingStrategy(), + }); + + const result = await pipeline.execute({ + schema, + sourceData, + targetData: { title: "Hallo", body: "Welt", items: [{ id: "a1", label: "Erste" }] }, + sourceLng: "en", + targetLng: "de", + sourceChangedByLeaf: { title: true }, + }); + + expect([...(result?.translatedPaths ?? [])]).toEqual(["title"]); + }); + }); }); 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 e7973b6b9..e3afe29de 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/TranslationPipeline.ts @@ -61,12 +61,12 @@ export class TranslationPipeline { targetData: config.targetData, sourceLng: config.sourceLng, targetLng: config.targetLng, + sourceChangedByLeaf: config.sourceChangedByLeaf, }; for (const stage of this.stages) { ctx = await stage.execute(ctx); - // Early exit checks if (ctx.fieldChunks !== undefined && ctx.fieldChunks.length === 0) { return null; } @@ -81,6 +81,7 @@ export class TranslationPipeline { return { translatedData: ctx.filteredData, + translatedPaths: (ctx.fieldChunks ?? []).map((chunk) => chunk.idPath), }; } } 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 6e63ddc41..544246d8b 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 @@ -60,9 +60,9 @@ const bracketProvider = ( type Wrapper = { type: string; fields?: { url?: string }; children?: { text?: string }[] }; type Paragraph = { children: ({ text?: string } & Partial)[] }; -const paragraphsOf = (data: Record | null): Paragraph[] => { - if (!data) throw new Error("translateContent returned nothing"); - return (data.body as { root: { children: Paragraph[] } }).root.children; +const paragraphsOf = (result: { translatedData: Record } | null): Paragraph[] => { + if (!result) throw new Error("translateContent returned nothing"); + return (result.translatedData.body as { root: { children: Paragraph[] } }).root.children; }; describe("container mode", () => { 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 d9d845c82..eca663a31 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 @@ -21,7 +21,8 @@ export class FieldChunkCollectorStage implements PipelineStage { ctx.filteredData, ctx.sourceData, ctx.targetData, - this.strategy + this.strategy, + ctx.sourceChangedByLeaf ); 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 b5726e456..a11638d57 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 @@ -18,7 +18,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("title"); - expect(chunks[0].path).toEqual(["title"]); + expect(chunks[0].idPath).toBe("title"); expect(chunks[0].dataRef).toBe(data); expect(chunks[0].schema.type).toBe("text"); }); @@ -95,7 +95,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("title"); - expect(chunks[0].path).toEqual(["meta", "title"]); + expect(chunks[0].idPath).toBe("meta.title"); expect(chunks[0].dataRef).toBe(data.meta); }); @@ -119,7 +119,7 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["level1", "level2", "title"]); + expect(chunks[0].idPath).toBe("level1.level2.title"); expect(chunks[0].dataRef).toBe(data.level1.level2); }); }); @@ -144,8 +144,8 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(2); - expect(chunks[0].path).toEqual(["items", "0", "label"]); - expect(chunks[1].path).toEqual(["items", "1", "label"]); + expect(chunks[0].idPath).toBe("items.1.label"); + expect(chunks[1].idPath).toBe("items.2.label"); expect(chunks[0].dataRef).toBe(data.items[0]); expect(chunks[1].dataRef).toBe(data.items[1]); }); @@ -163,7 +163,7 @@ describe("FieldChunkCollector", () => { const collector = new FieldChunkCollector(schema, data, data, {}, strategy); const chunks = collector.collect(); - expect(chunks[0].path).toEqual(["items", "0", "text"]); + expect(chunks[0].idPath).toBe("items.1.text"); }); }); @@ -189,7 +189,7 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["layout", "0", "content"]); + expect(chunks[0].idPath).toBe("layout.1:text.content"); expect(chunks[0].dataRef).toBe(data.layout[0]); }); @@ -268,7 +268,7 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["title"]); + expect(chunks[0].idPath).toBe("title"); }); it("collects fields from row", () => { @@ -284,7 +284,7 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["title"]); + expect(chunks[0].idPath).toBe("title"); }); it("collects fields from unnamed group (no name property)", () => { @@ -303,8 +303,8 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(2); - expect(chunks[0].path).toEqual(["title"]); - expect(chunks[1].path).toEqual(["description"]); + expect(chunks[0].idPath).toBe("title"); + expect(chunks[1].idPath).toBe("description"); expect(chunks[0].dataRef).toBe(data); }); }); @@ -331,7 +331,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("label"); - expect(chunks[0].path).toEqual(["section", "items", "0", "label"]); + expect(chunks[0].idPath).toBe("section.items.1.label"); expect(chunks[0].dataRef).toBe(data.section.items[0]); }); @@ -356,7 +356,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("title"); - expect(chunks[0].path).toEqual(["items", "0", "meta", "title"]); + expect(chunks[0].idPath).toBe("items.1.meta.title"); expect(chunks[0].dataRef).toBe(data.items[0].meta); }); @@ -386,7 +386,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("body"); - expect(chunks[0].path).toEqual(["hero", "content", "0", "body"]); + expect(chunks[0].idPath).toBe("hero.content.1:text.body"); expect(chunks[0].dataRef).toBe(data.hero.content[0]); }); @@ -416,7 +416,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("title"); - expect(chunks[0].path).toEqual(["layout", "0", "meta", "title"]); + expect(chunks[0].idPath).toBe("layout.1:card.meta.title"); expect(chunks[0].dataRef).toBe(data.layout[0].meta); }); @@ -448,7 +448,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("content"); - expect(chunks[0].path).toEqual(["sections", "0", "blocks", "0", "content"]); + expect(chunks[0].idPath).toBe("sections.1.blocks.b1:text.content"); expect(chunks[0].dataRef).toBe(data.sections[0].blocks[0]); }); @@ -471,7 +471,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("title"); - expect(chunks[0].path).toEqual(["seo", "title"]); + expect(chunks[0].idPath).toBe("seo.title"); expect(chunks[0].dataRef).toBe(data.seo); }); @@ -490,7 +490,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("meta"); - expect(chunks[0].path).toEqual(["meta"]); + expect(chunks[0].idPath).toBe("meta"); }); it("collects 4-level nested structure (tabs > group > array > blocks)", () => { @@ -541,7 +541,7 @@ describe("FieldChunkCollector", () => { expect(chunks).toHaveLength(1); expect(chunks[0].key).toBe("body"); - expect(chunks[0].path).toEqual(["content", "sections", "items", "0", "blocks", "0", "body"]); + expect(chunks[0].idPath).toBe("content.sections.items.1.blocks.b1:text.body"); expect(chunks[0].dataRef).toBe(data.content.sections.items[0].blocks[0]); }); @@ -594,12 +594,12 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(5); - expect(chunks.map((c) => c.path)).toEqual([ - ["page", "title"], - ["page", "sections", "0", "heading"], - ["page", "sections", "0", "content", "0", "text"], - ["page", "sections", "1", "heading"], - ["page", "sections", "1", "content", "0", "text"], + expect(chunks.map((c) => c.idPath)).toEqual([ + "page.title", + "page.sections.1.heading", + "page.sections.1.content.b1:paragraph.text", + "page.sections.2.heading", + "page.sections.2.content.b2:paragraph.text", ]); }); }); @@ -753,7 +753,7 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["items", "1", "label"]); + expect(chunks[0].idPath).toBe("items.2.label"); }); it("applies SkipExisting to nested group fields", () => { @@ -781,7 +781,7 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["meta", "description"]); + expect(chunks[0].idPath).toBe("meta.description"); }); it("applies SkipExisting to blocks fields", () => { @@ -826,7 +826,7 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["layout", "1", "content"]); + expect(chunks[0].idPath).toBe("layout.2:text.content"); }); it("pairs the target by id, not position, for reordered localized blocks (SkipExisting)", () => { @@ -870,10 +870,10 @@ describe("FieldChunkCollector", () => { ); const chunks = collector.collect(); - // Only id-2's content (source index 1) is collected; id-1 is skipped (its target is non-empty). - // Positional pairing would have collected index 0 and skipped index 1 — both wrong. expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["layout", "1", "content"]); + expect(chunks[0].idPath, "positional pairing would have taken index 0").toBe( + "layout.2:text.content" + ); expect(chunks[0].dataRef.content).toBe("World"); }); }); @@ -979,7 +979,7 @@ describe("FieldChunkCollector", () => { const chunks = collector.collect(); expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["items", "0", "label"]); + expect(chunks[0].idPath).toBe("items.1.label"); }); it("falls back to empty source/target per item when the target array is shorter (SkipExisting)", () => { @@ -1007,9 +1007,8 @@ describe("FieldChunkCollector", () => { ); const chunks = collector.collect(); - // item 0 has an existing target → skipped; item 1's target falls back to {} → collected expect(chunks).toHaveLength(1); - expect(chunks[0].path).toEqual(["items", "1", "label"]); + expect(chunks[0].idPath).toBe("items.2.label"); }); }); @@ -1033,4 +1032,61 @@ describe("FieldChunkCollector", () => { 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 c1e8d88ef..1d90f13cb 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,3 +1,5 @@ +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 { ChildCursor, FieldLike, FieldWalker } from "../../../kernel/field-traversal/index.js"; import { @@ -6,6 +8,7 @@ import { walkFields, } from "../../../kernel/field-traversal/index.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"; @@ -14,7 +17,7 @@ type Cursor = { data: Record; source: Record; target: Record; - path: string[]; + segments: PathSegment[]; }; const asObject = (value: unknown): Record => (isObject(value) ? value : {}); @@ -40,26 +43,28 @@ export class FieldChunkCollector { private readonly sourceData: Record; private readonly targetData: Record; private readonly strategy: TranslationStrategy; + private readonly sourceChangedByLeaf?: ChangedLeaves; constructor( schema: FieldLike[], filteredData: Record, sourceData: Record, targetData: Record, - strategy: TranslationStrategy + strategy: TranslationStrategy, + sourceChangedByLeaf?: ChangedLeaves ) { this.schema = schema; this.filteredData = filteredData; this.sourceData = sourceData; this.targetData = targetData; this.strategy = strategy; + this.sourceChangedByLeaf = sourceChangedByLeaf; } - /** Collects translatable field chunks that need translation. */ collect(): FieldChunk[] { const selected: { dataRef: Record; key: string; sourceValue: unknown }[] = []; const chunks: FieldChunk[] = []; - const { strategy } = this; + const { strategy, sourceChangedByLeaf } = this; const walker: FieldWalker = { enterObject(field, cursor) { @@ -69,7 +74,7 @@ export class FieldChunkCollector { data: value, source: asObject(cursor.source[field.name]), target: asObject(cursor.target[field.name]), - path: [...cursor.path, field.name], + segments: [...cursor.segments, { kind: "key", name: field.name }], }; }, @@ -95,7 +100,11 @@ export class FieldChunkCollector { data: item, source: sourceItem, target: matchElementById(targetArr, sourceItem, isBlocks), - path: [...cursor.path, field.name, String(index)], + segments: [ + ...cursor.segments, + { kind: "key", name: field.name }, + elementSegment(sourceItem, isBlocks, index), + ], }, fields, key: index, @@ -110,13 +119,21 @@ export class FieldChunkCollector { const sourceValue = cursor.source[field.name]; const targetValue = cursor.target[field.name]; - if (isTranslatableLeaf(field) && strategy.shouldTranslate({ sourceValue, targetValue })) { + const idPath = makeIdPath([...cursor.segments, { kind: "key", name: field.name }]); + if ( + isTranslatableLeaf(field) && + strategy.shouldTranslate({ + sourceValue, + targetValue, + sourceChanged: sourceChangedByLeaf?.[idPath], + }) + ) { selected.push({ dataRef: cursor.data, key: field.name, sourceValue }); chunks.push({ schema: field, dataRef: cursor.data, key: field.name, - path: [...cursor.path, field.name], + idPath, }); } return undefined; @@ -129,7 +146,7 @@ export class FieldChunkCollector { walkFields( this.schema, - { data: this.filteredData, source: this.sourceData, target: this.targetData, path: [] }, + { data: this.filteredData, source: this.sourceData, target: this.targetData, segments: [] }, walker ); 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 ce0b86c54..7b3736afd 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 @@ -69,9 +69,34 @@ const doc = { ], }; -/** Reconstruct the pipeline's per-field source text from the collector + expanders. */ +/** + * What the collector is really handed: `DataReconciler` strips a per-locale row's `id` before the + * collect walk ever runs. Feeding it the untouched document hides the one failure this guard exists + * to catch — a collector that names leaves from the walked data instead of from the source. + */ +const asReconciled = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(asReconciled); + if (value === null || typeof value !== "object") return value; + const { id: _strippedByTheReconciler, ...rest } = value as Record; + return Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, asReconciled(v)])); +}; + +const filtered = () => asReconciled(structuredClone(doc)) as Record; + +const pipelineAddresses = (): string[] => + new FieldChunkCollector( + schema as unknown as Field[], + filtered(), + structuredClone(doc), + {}, + new OverwriteStrategy() + ) + .collect() + .map((chunk) => chunk.idPath) + .sort(); + const pipelineFieldTexts = (): string[] => { - const filteredData = structuredClone(doc); + const filteredData = filtered(); const source = structuredClone(doc); const chunks: FieldChunk[] = new FieldChunkCollector( schema as unknown as Field[], @@ -109,6 +134,16 @@ describe("projection / translation drift guard", () => { expect(pipelineFieldTexts()).toHaveLength(projectTranslatableContent(doc, schema).length); }); + // If the two sides name a leaf differently, every per-leaf fingerprint lookup misses and nothing + // ever reads as current. + it("both give a leaf the same address, not merely the same text", () => { + const projectionAddresses = projectTranslatableContent(doc, schema) + .map((entry) => String(entry.idPath)) + .sort(); + + expect(pipelineAddresses()).toEqual(projectionAddresses); + }); + it("excludes the same non-translatable content from both", () => { const projectionTexts = projectTranslatableContent(doc, schema).map((e) => e.text); expect(projectionTexts).not.toContain("secret"); // excluded field diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/TextChunkExpander.test.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/TextChunkExpander.test.ts index 6d8d466d6..9be7942de 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/TextChunkExpander.test.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/stages/text-expander/TextChunkExpander.test.ts @@ -4,6 +4,7 @@ import type { LeafFieldLike } from "../../../kernel/field-traversal/types.js"; import { TextChunkExpander } from "./TextChunkExpander.js"; import { PlainTextExpander } from "./PlainTextExpander.js"; import { RichTextExpander } from "./RichTextExpander.js"; +import type { IdPath } from "../../../domain/content-projection/idPath.js"; const createFieldChunk = ( name: string, @@ -17,7 +18,7 @@ const createFieldChunk = ( schema: { name, type, ...overrides } as LeafFieldLike, dataRef: data, key: name, - path: [name], + idPath: "name" as IdPath, }, data, }; @@ -431,13 +432,13 @@ describe("TextChunkExpander", () => { schema: { name: "title", type: "text" } as LeafFieldLike, dataRef: data1, key: "title", - path: ["title"], + idPath: "title" as IdPath, }, { schema: { name: "description", type: "text" } as LeafFieldLike, dataRef: data2, key: "description", - path: ["description"], + idPath: "description" as IdPath, }, ]; @@ -476,13 +477,13 @@ describe("TextChunkExpander", () => { schema: { name: "content", type: "richText" } as LeafFieldLike, dataRef: data1, key: "content", - path: ["content"], + idPath: "content" as IdPath, }, { schema: { name: "title", type: "text" } as LeafFieldLike, dataRef: data2, key: "title", - path: ["title"], + idPath: "title" as IdPath, }, ]; @@ -500,7 +501,7 @@ describe("TextChunkExpander", () => { schema: { name: "title", type: "text" } as LeafFieldLike, dataRef: data, key: "title", - path: ["title"], + idPath: "title" as IdPath, }, ]; @@ -518,7 +519,7 @@ describe("TextChunkExpander", () => { schema: { name: "title", type: "text" } as LeafFieldLike, dataRef: data, key: "title", - path: ["title"], + idPath: "title" as IdPath, }, ]; 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 index 470afd522..3f204df9d 100644 --- 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 @@ -100,4 +100,31 @@ describe("SkipExistingStrategy", () => { }); }); }); + + 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 index c77b96015..b1c0b2bd3 100644 --- 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 @@ -3,14 +3,12 @@ import { isSerializedLexicalRoot } from "../../kernel/lexical/guards.js"; import { isEmpty } from "../../kernel/utils/isEmpty.js"; import type { TranslationStrategy, StrategyContext } from "./TranslationStrategy.interface.js"; -/** - * Only translates fields that are empty or missing in the target locale. - * Existing translations are preserved. - */ +/** 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; - return this.isEmptyValue(ctx.targetValue); + if (this.isEmptyValue(ctx.targetValue)) return true; + return ctx.sourceChanged === true; } private isEmptyValue(value: unknown): boolean { 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 index c92c940be..9b5a506eb 100644 --- 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 @@ -4,14 +4,11 @@ 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; }; -/** - * Strategy interface for determining which fields should be translated. - * Implement this interface to create custom translation strategies. - * - * Works at data level - called for each translatable field during filtering. - */ +/** Called once per translatable leaf during filtering, to decide whether it is translated. */ export interface TranslationStrategy { /** * Determines if a field value should be translated. 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 a383c85f2..e8497cb56 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 @@ -17,14 +17,16 @@ const fakeProvider: TranslationProvider = { }, }; -const run = (schema: Field[], sourceData: Record) => - translateContent({ - schema, - sourceData, - sourceLng: "en", - targetLng: "de", - translationProvider: fakeProvider, - }); +const run = async (schema: Field[], sourceData: Record) => + ( + await translateContent({ + schema, + sourceData, + sourceLng: "en", + targetLng: "de", + translationProvider: fakeProvider, + }) + )?.translatedData ?? null; const richTextNode = (value: string) => ({ type: "text", @@ -144,7 +146,8 @@ describe("translateContent", () => { translationProvider: fakeProvider, strategy: "skip_existing", }); - expect(result).toEqual({ title: "T:hello" }); + expect(result?.translatedData).toEqual({ title: "T:hello" }); + expect(result?.translatedPaths).toEqual(["title"]); }); describe("the caller's source document", () => { 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 84bead538..62887a556 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/translateContent.ts @@ -4,6 +4,8 @@ import { TranslationPipeline } from "./TranslationPipeline.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]`). */ @@ -22,30 +24,23 @@ export type TranslateContentArgs = { translationProvider: TranslationProvider; /** @default 'overwrite' */ strategy?: TranslationStrategyName; + sourceChangedByLeaf?: ChangedLeaves; /** - * Translate each rich-text container (paragraph, heading, list item) as one marked string - * instead of node by node, so the model may reorder its pieces. - * - * Ignored unless the provider declares `capabilities.inlineMarks`: a provider that is not a - * language model would mangle the marks. + * Translate each rich-text container as one marked string instead of node by node, so the model + * may reorder its pieces. Silently ignored unless the provider declares `capabilities.inlineMarks`. * * @default false */ inlineMarks?: boolean; }; +export type TranslatedContent = PipelineResult; + /** - * Translate a content object over a schema subtree — no DB, no document. + * Translate a content object over a schema subtree — pure: no DB, no document. * - * A thin reusable entry over {@link TranslationPipeline}, which is already pure - * and walks any `Field[]` + matching data (a subtree + partial data works - * unchanged). The document level routes through this wrapper today (instead of - * constructing the pipeline inline); the upcoming field level will too — - * passing a single declared field's subtree + its current unsaved form value. - * - * Only `localized` text/richText leaves are translated; non-localized values - * are reconciled through unchanged. Returns the translated data (same shape as - * `sourceData`) or `null` when nothing was translatable. + * Only `localized` text/richText leaves are translated; everything else is reconciled through + * unchanged. `null` when the subtree held nothing translatable. */ export async function translateContent({ schema, @@ -56,7 +51,8 @@ export async function translateContent({ translationProvider, strategy = "overwrite", inlineMarks = false, -}: TranslateContentArgs): Promise | null> { + sourceChangedByLeaf, +}: TranslateContentArgs): Promise { const marksUsable = inlineMarks && translationProvider.capabilities?.inlineMarks === true; const pipeline = new TranslationPipeline({ @@ -65,13 +61,12 @@ export async function translateContent({ textExpanders: marksUsable ? [new RichContainerExpander(), new PlainTextExpander()] : undefined, }); - const result = await pipeline.execute({ + return await pipeline.execute({ schema, sourceData, targetData, sourceLng, targetLng, + sourceChangedByLeaf, }); - - return result ? result.translatedData : null; } diff --git a/packages/payload-plugin-translator/src/core/translation-pipeline/types/FieldChunk.ts b/packages/payload-plugin-translator/src/core/translation-pipeline/types/FieldChunk.ts index 733f172c8..0b2dbdf6f 100644 --- a/packages/payload-plugin-translator/src/core/translation-pipeline/types/FieldChunk.ts +++ b/packages/payload-plugin-translator/src/core/translation-pipeline/types/FieldChunk.ts @@ -1,18 +1,12 @@ +import type { IdPath } from "../../domain/content-projection/idPath.js"; import type { LeafFieldLike } from "../../kernel/field-traversal/types.js"; -/** - * Represents a field-level chunk containing schema metadata. - * Used in stages 1-3 where schema awareness is needed. - * - * Contains a reference to the parent data object for later mutation. - */ export type FieldChunk = { /** The leaf field schema (only `type`/`name` are read downstream). */ schema: LeafFieldLike; /** Reference to the parent data object (for mutation) */ dataRef: Record; - /** The key in the dataRef object */ key: string; - /** Full path from root for strategy lookup in targetData */ - path: string[]; + /** The leaf's {@link IdPath} — the same address a provenance receipt is keyed on. */ + idPath: IdPath; }; 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 df84461c0..9b536691a 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,22 +1,21 @@ +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"; -/** - * Pipeline configuration for translation execution. - * Contains only data transformation inputs — no persistence concerns. - */ export type PipelineConfig = { schema: FieldLike[]; sourceData: Record; targetData: Record; sourceLng: string; targetLng: string; + sourceChangedByLeaf?: ChangedLeaves; }; -/** - * Result of pipeline execution. - * Contains translated data ready to be saved. - */ export type PipelineResult = { - /** Mutated data with translations applied */ translatedData: Record; + /** + * The leaves actually sent for translation, by the same address a provenance receipt is keyed on. + * A caller writing a receipt may only claim these — see `ProvenanceService.record`. + */ + translatedPaths: IdPath[]; }; 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 f7c9c91d9..d8aa41255 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,3 +1,4 @@ +import type { ChangedLeaves } from "../../domain/provenance/staleness.js"; import type { FieldLike } from "../../kernel/field-traversal/index.js"; import type { FieldChunk } from "./FieldChunk.js"; import type { TextChunk } from "./TextChunk.js"; @@ -12,6 +13,7 @@ export type PipelineContext = { readonly targetData: Record; readonly sourceLng: string; readonly targetLng: string; + readonly sourceChangedByLeaf?: ChangedLeaves; /** Full document shape with reconciled source/target values */ filteredData?: Record; diff --git a/packages/payload-plugin-translator/src/index.ts b/packages/payload-plugin-translator/src/index.ts index b14f0eee6..d44b9ad05 100644 --- a/packages/payload-plugin-translator/src/index.ts +++ b/packages/payload-plugin-translator/src/index.ts @@ -9,8 +9,6 @@ export type { TranslationLifecycleCallbacks, } from "./server/modules/lifecycle/index.js"; -// Provenance — the durable per-locale record shape (opt-in `provenance` sidecar). The store/key and -// the Payload-backed impl stay internal; consumers only read the sidecar collection. export type { TranslationProvenanceRecord } from "./core/index.js"; // Access control 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 913f069f8..bcde79d2d 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 @@ -1,12 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Field, Payload, PayloadRequest } from "payload"; -import { computeSourceFingerprint } from "../../../core/domain/content-projection/computeSourceFingerprint.js"; -import type { - ProvenanceStore, - TranslationProvenanceRecord, -} from "../../../core/domain/provenance/index.js"; -import { ProvenanceService } from "../../modules/provenance/index.js"; +import { computeFieldFingerprints } from "../../../core/domain/content-projection/computeFieldFingerprints.js"; +import type { ProvenanceStore, ProvenanceReceipt } 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"; @@ -16,12 +13,10 @@ import type { StalenessConfig } from "./model.js"; const COLLECTION = "posts"; const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const sourceDoc = { id: "1", title: "Hello" }; -// The fingerprint recorded at translation time — recomputed identically by the service. -const recordedFingerprint = computeSourceFingerprint(sourceDoc, schema); +const recordedFields = computeFieldFingerprints(sourceDoc, schema); +const recordedFingerprint = { kind: "fields", hashes: recordedFields } as const; -function makeRecord( - overrides: Partial = {} -): TranslationProvenanceRecord { +function makeRecord(overrides: Partial = {}): ProvenanceReceipt { return { collectionSlug: COLLECTION, documentId: "1", @@ -61,9 +56,9 @@ const schemaMap = new Map([["posts", schema]]) as CollectionSchemaMap; function makeConfig(store: ProvenanceStore | null): StalenessConfig { return { availableCollections: new Set(["posts"]) as StalenessConfig["availableCollections"], - // The fingerprint policy lives in ProvenanceService now; the handlers only delegate. Wrapping the - // mock store in a real service keeps this suite exercising the full fetch+fingerprint+compare path. - provenanceServiceFactory: store ? (p) => new ProvenanceService(p, store, schemaMap) : undefined, + provenanceServiceFactory: store + ? (p) => new ProvenanceService(store, schemaMap, {}, provenanceIo(p)) + : undefined, }; } @@ -102,7 +97,11 @@ describe("GetDocumentStalenessHandler", () => { it("reports is_stale=true when the source drifted", async () => { const store = makeStore({ - findByDocument: vi.fn().mockResolvedValue([makeRecord({ sourceFingerprint: "fp-old" })]), + findByDocument: vi + .fn() + .mockResolvedValue([ + makeRecord({ sourceFingerprint: { kind: "fields", hashes: { title: "fp-old" } } }), + ]), }); const res = await new GetDocumentStalenessHandler(makeConfig(store)).handle(readReq()); expect((await bodyOf(res)).locales[0].is_stale).toBe(true); 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 87730f692..1e679c382 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 @@ -49,7 +49,10 @@ describe("TranslateDocumentHandler — it asks before it writes", () => { beforeEach(async () => { vi.clearAllMocks(); - (await pipeline()).mockResolvedValue({ title: "Titel", body: "Text" }); + (await pipeline()).mockResolvedValue({ + translatedData: { title: "Titel", body: "Text" }, + translatedPaths: ["title", "body"], + }); provider = { translate: vi.fn().mockResolvedValue({}) }; const schemaMap = new Map([ ["posts" as CollectionSlug, [{ name: "title", type: "text", localized: true }]], 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 5d9730090..27e37c8f8 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 @@ -8,7 +8,9 @@ import type { TranslateDocumentInput } from "../model.js"; import { asRequester } from "../../../shared/payload/RequestScope.shapes.js"; vi.mock("../../../../core/translation-pipeline/index.js", () => ({ - translateContent: vi.fn().mockResolvedValue({ title: "Titel" }), + translateContent: vi + .fn() + .mockResolvedValue({ translatedData: { title: "Titel" }, translatedPaths: ["title"] }), })); vi.mock("../translationPermission.js", () => ({ 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 0e6a263af..6966cec41 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 @@ -7,7 +7,9 @@ import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap. import type { TranslateDocumentInput } from "../model.js"; vi.mock("../../../../core/translation-pipeline/index.js", () => ({ - translateContent: vi.fn().mockResolvedValue({ title: "Titel" }), + translateContent: vi + .fn() + .mockResolvedValue({ translatedData: { title: "Titel" }, translatedPaths: ["title"] }), })); vi.mock("../translationPermission.js", () => ({ 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 944a21081..174807409 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 @@ -11,20 +11,17 @@ import type { TranslationProvider } from "../../../../core/domain/translation-pr 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 } from "../../../modules/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"; -// Mock the translation core — the handler's unit tests isolate its -// orchestration (fetch / strategy plumbing / save), not the pipeline itself. -// translateContent returns the translated data directly, or null when there is -// nothing to translate. vi.mock("../../../../core/translation-pipeline/index.js", () => ({ translateContent: vi.fn().mockResolvedValue(null), })); -// Provenance fingerprinting is the core's job and tested there; here we pin a fixed hash so the -// handler test asserts only the record the handler builds and hands to the store. +vi.mock("../../../../core/domain/content-projection/computeFieldFingerprints.js", () => ({ + computeFieldFingerprints: vi.fn(() => ({ title: "fp-fixed" })), +})); vi.mock("../../../../core/domain/content-projection/computeSourceFingerprint.js", () => ({ computeSourceFingerprint: vi.fn(() => "fp-fixed"), })); @@ -229,7 +226,8 @@ describe("TranslateDocumentHandler", () => { it("returns success after saving translated document", async () => { const { translateContent } = await import("../../../../core/translation-pipeline/index.js"); (translateContent as unknown as ReturnType).mockResolvedValue({ - title: "Übersetzter Titel", + translatedData: { title: "Übersetzter Titel" }, + translatedPaths: ["title"], }); const input = createInput(); @@ -243,7 +241,8 @@ describe("TranslateDocumentHandler", () => { beforeEach(async () => { const { translateContent } = await import("../../../../core/translation-pipeline/index.js"); (translateContent as unknown as ReturnType).mockResolvedValue({ - title: "Translated", + translatedData: { title: "Translated" }, + translatedPaths: ["title"], }); }); @@ -412,27 +411,31 @@ describe("TranslateDocumentHandler", () => { dismiss: vi.fn(), deleteByDocument: vi.fn(), }; - // The fingerprint policy lives in ProvenanceService; the handler only delegates. Wrap the mock - // store in a real service so these tests still assert the record the store receives. + // A real `ProvenanceService` over a mock store: the record's shape is the service's policy, + // not the handler's. serviceFactory = vi.fn( (payload) => - new ProvenanceService(payload, store as unknown as ProvenanceStore, mockSchemaMap) + new ProvenanceService( + store as unknown as ProvenanceStore, + mockSchemaMap, + {}, + provenanceIo(payload) + ) ); }); - const withTranslatedData = async () => { + const withTranslatedData = async (translatedPaths: string[] = ["title"]) => { const { translateContent } = await import("../../../../core/translation-pipeline/index.js"); (translateContent as unknown as ReturnType).mockResolvedValue({ - title: "Hallo", + translatedData: { title: "Hallo" }, + translatedPaths, }); }; it("upserts a provenance record after a successful translation", async () => { await withTranslatedData(); - const { computeSourceFingerprint } = - await import("../../../../core/domain/content-projection/computeSourceFingerprint.js"); - // Distinguish source vs. target findByID calls by locale so this assertion actually - // proves the handler fingerprints the source document, not the target one. + const { computeFieldFingerprints } = + await import("../../../../core/domain/content-projection/computeFieldFingerprints.js"); (mockPayload.findByID as ReturnType).mockImplementation( ({ locale }: { locale: string }) => Promise.resolve( @@ -448,7 +451,7 @@ describe("TranslateDocumentHandler", () => { createInput({ collection: "posts" as CollectionSlug, sourceLng: "en", targetLng: "de" }) ); - expect(computeSourceFingerprint).toHaveBeenCalledWith({ id: "doc-123", title: "Source" }, [ + expect(computeFieldFingerprints).toHaveBeenCalledWith({ id: "doc-123", title: "Source" }, [ { name: "title", type: "text", localized: true }, ]); expect(serviceFactory).toHaveBeenCalledWith(mockPayload, {}); @@ -458,21 +461,23 @@ describe("TranslateDocumentHandler", () => { documentId: "doc-123", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: "fp-fixed", + sourceFingerprint: { kind: "fields", hashes: { title: "fp-fixed" } }, dismissedFingerprint: null, }) ); const record = store.upsert.mock.calls[0][0] as { translatedAt: string }; - expect(new Date(record.translatedAt).toISOString()).toBe(record.translatedAt); + expect( + new Date(record.translatedAt).toISOString(), + "translatedAt is stored as an ISO-8601 string" + ).toBe(record.translatedAt); }); it("fingerprints the source document, not whatever the pipeline hands back", async () => { - // The stub below mutates its `sourceData` argument on purpose. The real pipeline no longer - // does — it detaches object-valued leaves — so this stands as the handler-level guard that a - // regression there cannot silently poison the staleness baseline. + // The real pipeline no longer mutates `sourceData` (it detaches object-valued leaves); the + // hostile stub below keeps a handler-level guard in case that regresses. const { translateContent } = await import("../../../../core/translation-pipeline/index.js"); - const { computeSourceFingerprint } = - await import("../../../../core/domain/content-projection/computeSourceFingerprint.js"); + const { computeFieldFingerprints } = + await import("../../../../core/domain/content-projection/computeFieldFingerprints.js"); (mockPayload.findByID as ReturnType).mockImplementation( ({ locale }: { locale: string }) => @@ -487,16 +492,18 @@ describe("TranslateDocumentHandler", () => { (translateContent as unknown as ReturnType).mockImplementation( async ({ sourceData }: { sourceData: Record }) => { sourceData.title = "TRANSLATED (pipeline mutation)"; - return { title: "TRANSLATED (pipeline mutation)" }; + return { + translatedData: { title: "TRANSLATED (pipeline mutation)" }, + translatedPaths: ["title"], + }; } ); - // Snapshot exactly what the fingerprint saw, at call time. let fingerprintedDoc: unknown; - (computeSourceFingerprint as unknown as ReturnType).mockImplementation( + (computeFieldFingerprints as unknown as ReturnType).mockImplementation( (doc: unknown) => { fingerprintedDoc = structuredClone(doc); - return "fp-fixed"; + return { title: "fp-fixed" }; } ); @@ -507,7 +514,9 @@ describe("TranslateDocumentHandler", () => { expect(fingerprintedDoc).toEqual({ id: "doc-123", title: "Original source" }); expect(store.upsert).toHaveBeenCalledWith( - expect.objectContaining({ sourceFingerprint: "fp-fixed" }) + expect.objectContaining({ + sourceFingerprint: { kind: "fields", hashes: { title: "fp-fixed" } }, + }) ); }); @@ -542,4 +551,147 @@ describe("TranslateDocumentHandler", () => { ).toHaveBeenCalled(); }); }); + + describe("what the handler tells provenance about the run", () => { + it("claims the leaf the pipeline sent and marks the one it did not", async () => { + // The module mock returns a fixed map, so it has to name BOTH leaves — otherwise `tagline` + // could never reach the receipt whatever the merge rule did, and this would pass on the mock. + const { computeFieldFingerprints } = + await import("../../../../core/domain/content-projection/computeFieldFingerprints.js"); + (computeFieldFingerprints as unknown as ReturnType).mockReturnValue({ + title: "fp-title", + tagline: "fp-tagline", + }); + + const store = { + upsert: vi.fn(), + find: vi.fn().mockResolvedValue(null), + findByDocument: vi.fn(), + dismiss: vi.fn(), + deleteByDocument: vi.fn(), + }; + const serviceFactory = vi.fn( + (payload) => + new ProvenanceService( + store as unknown as ProvenanceStore, + mockSchemaMap, + {}, + provenanceIo(payload) + ) + ); + const { translateContent } = await import("../../../../core/translation-pipeline/index.js"); + (translateContent as unknown as ReturnType).mockResolvedValue({ + translatedData: { title: "Hallo" }, + translatedPaths: ["title"], + }); + (mockPayload.findByID as ReturnType).mockResolvedValue({ + id: "doc-123", + title: "Source", + tagline: "Untouched", + }); + + const underTest = new TranslateDocumentHandler( + mockTranslationProvider, + mockSchemaMap, + 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 }; + }; + expect(written.sourceFingerprint).toEqual({ + kind: "fields", + hashes: { title: "fp-title", tagline: null }, + }); + }); + + it("hands the pipeline the leaves the receipt can answer for, and no others", async () => { + const store = { + upsert: vi.fn(), + find: vi.fn().mockResolvedValue({ + collectionSlug: "pages", + documentId: "doc-123", + targetLocale: "de", + sourceLocale: "en", + sourceFingerprint: { kind: "fields", hashes: { 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( + store as unknown as ProvenanceStore, + mockSchemaMap, + {}, + provenanceIo(payload) + ) + ); + const { translateContent } = await import("../../../../core/translation-pipeline/index.js"); + (translateContent as unknown as ReturnType).mockResolvedValue({ + translatedData: { title: "Hallo" }, + translatedPaths: ["title"], + }); + (mockPayload.findByID as ReturnType).mockResolvedValue({ + id: "doc-123", + title: "Source", + }); + + const underTest = new TranslateDocumentHandler( + mockTranslationProvider, + mockSchemaMap, + serviceFactory as unknown as ProvenanceServiceFactory + ); + await underTest.handle(mockPayload, createInput({})); + + const passed = (translateContent as unknown as ReturnType).mock.calls[0][0] as { + sourceChangedByLeaf?: Record; + }; + expect(passed.sourceChangedByLeaf).toEqual({ title: true }); + }); + }); + + it("translates anyway when the prior receipt cannot be read", async () => { + const store = { + upsert: vi.fn(), + find: vi.fn().mockRejectedValue(new Error("sidecar unavailable")), + findByDocument: vi.fn(), + dismiss: vi.fn(), + deleteByDocument: vi.fn(), + }; + const serviceFactory = vi.fn( + (payload) => + new ProvenanceService( + store as unknown as ProvenanceStore, + mockSchemaMap, + {}, + provenanceIo(payload) + ) + ); + const { translateContent } = await import("../../../../core/translation-pipeline/index.js"); + (translateContent as unknown as ReturnType).mockResolvedValue({ + translatedData: { title: "Hallo" }, + translatedPaths: ["title"], + }); + (mockPayload.findByID as ReturnType).mockResolvedValue({ + id: "doc-123", + title: "Source", + }); + + const underTest = new TranslateDocumentHandler( + mockTranslationProvider, + mockSchemaMap, + serviceFactory as unknown as ProvenanceServiceFactory + ); + + await expect(underTest.handle(mockPayload, createInput({}))).resolves.toEqual({ + success: true, + }); + expect(mockPayload.update, "the translation must still be saved").toHaveBeenCalled(); + }); }); 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 ac3b1dff8..8434a7c98 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 @@ -8,7 +8,9 @@ import type { TranslateDocumentInput } from "../model.js"; import type { ProvenanceServiceFactory } from "../../../modules/provenance/index.js"; vi.mock("../../../../core/translation-pipeline/index.js", () => ({ - translateContent: vi.fn().mockResolvedValue({ title: "Titel" }), + translateContent: vi + .fn() + .mockResolvedValue({ translatedData: { title: "Titel" }, translatedPaths: ["title"] }), })); const TX = "tx-42"; @@ -84,7 +86,8 @@ describe("TranslateDocumentHandler — the caller's transaction", () => { it("builds the provenance service on it, so the receipt rolls back with the translation", async () => { const serviceFactory = vi.fn().mockReturnValue({ - captureFingerprint: vi.fn().mockReturnValue("fp"), + captureFingerprint: vi.fn().mockReturnValue({ title: "fp" }), + lastTranslatedFrom: vi.fn().mockResolvedValue(null), record: vi.fn(), }); const provenanceHandler = new TranslateDocumentHandler( 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 b8c62d83b..1adbba638 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 @@ -21,6 +21,7 @@ import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateCon import type { TranslateDocumentInput, TranslateDocumentOutput } from "./model.js"; import { resolveTargetLayer } from "./targetLayer.js"; import type { PublishScope, TargetLayer } from "./targetLayer.js"; +import { changedLeaves } from "../../../core/domain/provenance/index.js"; const translatorWriteContext = () => ({ [AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true }); @@ -112,9 +113,15 @@ export class TranslateDocumentHandler implements Handler< if (!allowed) throw new TranslationRefused(collection, targetLng); const provenance = this.provenanceServiceFactory?.(payload, scope); - const sourceFingerprint = provenance?.captureFingerprint(collection, sourceData) ?? null; + const provenanceKey = { + collectionSlug: collection, + documentId: String(collectionId), + targetLocale: targetLng, + }; + const currentFields = provenance?.captureFingerprint(collection, sourceData) ?? null; + const previous = provenance ? await provenance.lastTranslatedFrom(provenanceKey) : null; - const translatedData = await this.translateOrWrap({ + const translated = await this.translateOrWrap({ schema, sourceData, targetData: currentTargetVersion, @@ -123,9 +130,11 @@ export class TranslateDocumentHandler implements Handler< translationProvider: this.translationProvider, strategy, inlineMarks: this.inlineMarks, + sourceChangedByLeaf: currentFields ? changedLeaves(previous, currentFields) : undefined, }); - if (translatedData) { + if (translated?.translatedData) { + const { translatedData, translatedPaths } = translated; await this.refuseUnlessAllowed(payload, input, translatedData, scope, requester); await this.saveTranslatedDocument( payload, @@ -136,15 +145,11 @@ export class TranslateDocumentHandler implements Handler< requester ); - if (provenance && sourceFingerprint !== null) { + if (provenance && currentFields !== null) { await provenance.record( - { - collectionSlug: collection, - documentId: String(collectionId), - targetLocale: targetLng, - sourceLocale: sourceLng, - }, - sourceFingerprint + { ...provenanceKey, sourceLocale: sourceLng }, + currentFields, + translatedPaths ); } } diff --git a/packages/payload-plugin-translator/src/server/features/translate-field/__tests__/handler.test.ts b/packages/payload-plugin-translator/src/server/features/translate-field/__tests__/handler.test.ts index 629eccb49..e7b0e5bdf 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-field/__tests__/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-field/__tests__/handler.test.ts @@ -101,7 +101,10 @@ const importTranslateContent = async () => describe("TranslateFieldHandler", () => { it("reads the source-locale value from the saved doc and translates a localized leaf", async () => { - (await importTranslateContent()).mockResolvedValue({ title: "Hallo" }); + (await importTranslateContent()).mockResolvedValue({ + translatedData: { title: "Hallo" }, + translatedPaths: ["title"], + }); const findByID = vi.fn().mockResolvedValue({ id: "p1", title: "Hello" }); const res = await handler.handle(makeReqWithPayload(baseBody, findByID)); @@ -151,7 +154,10 @@ describe("TranslateFieldHandler", () => { it("resolves a field inside a block via the saved doc's blockType and translates it", async () => { const translateContent = await importTranslateContent(); - (translateContent as ReturnType).mockResolvedValue({ headline: "Hallo" }); + (translateContent as ReturnType).mockResolvedValue({ + translatedData: { headline: "Hallo" }, + translatedPaths: ["headline"], + }); const res = await handler.handle( reqWithDoc( @@ -298,7 +304,10 @@ describe("TranslateFieldHandler", () => { it("never forwards a translate strategy (field translation always overwrites)", async () => { const translateContent = await importTranslateContent(); - (translateContent as ReturnType).mockResolvedValue({ title: "Hallo" }); + (translateContent as ReturnType).mockResolvedValue({ + translatedData: { title: "Hallo" }, + translatedPaths: ["title"], + }); await handler.handle(reqWithDoc({ id: "p1", title: "Hello" })); 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 c33bf4fc7..4ec60300c 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 @@ -147,7 +147,7 @@ export class TranslateFieldHandler { const result: FieldTranslationResult = { status: "translated", - value: translated[resolution.fieldName], + value: translated.translatedData[resolution.fieldName], }; return ServerResponse.success(result); }; 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 64e4bf983..8bd01c337 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,13 +1,21 @@ -import type { CollectionSlug, Payload } from "payload"; +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 } from "../../../core/domain/content-projection/computeFieldFingerprints.js"; import type { FieldLike } from "../../../core/kernel/field-traversal/index.js"; import { isRecordStale } from "../../../core/domain/provenance/index.js"; -import type { ProvenanceKey, ProvenanceStore } from "../../../core/domain/provenance/index.js"; +import type { + CurrentFingerprint, + ProvenanceKey, + ProvenanceStore, + SourceFingerprint, +} from "../../../core/domain/provenance/index.js"; +import { DECLINED, recordsEachLeaf } from "../../../core/domain/provenance/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 { fetchSourceDocument } from "../../shared/payload/sourceDocument.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 = { @@ -17,59 +25,56 @@ export type StalenessLocale = { translated_at: string; }; -/** Builds a {@link ProvenanceService} bound to a Payload instance; absent when provenance is disabled. */ -export type ProvenanceServiceFactory = ( - payload: Payload, - scope?: RequestScope -) => ProvenanceService; +function previousClaim( + previous: SourceFingerprint | null, + address: string +): string | typeof DECLINED { + if (!recordsEachLeaf(previous)) return DECLINED; + return previous.hashes[address] ?? DECLINED; +} /** - * The single owner of provenance fingerprint policy — how the source is hashed on write, re-hashed on - * read, and compared for staleness. The write path and the read path go through this one class, so - * they can never drift (the biggest correctness trap in staleness detection). Sits above the CRUD - * {@link ProvenanceStore} port; the port + `computeSourceFingerprint` + `isRecordStale` stay - * framework-agnostic in the core. + * The single owner of fingerprint policy: write and read hash through this one class, so they cannot + * drift. * - * Best-effort by contract, except where swallowing would hide a loss — see - * {@link swallowOrThrow}. + * Best-effort by contract, except where swallowing would hide a loss — see {@link swallowOrThrow}. */ export class ProvenanceService { - private readonly payload: Payload; private readonly store: ProvenanceStore; private readonly schemaMap: CollectionSchemaMap; private readonly scope: RequestScope; + private readonly logger: ProvenanceLogger; + private readonly readSource: SourceDocumentReader; constructor( - payload: Payload, store: ProvenanceStore, schemaMap: CollectionSchemaMap, - scope: RequestScope = {} + scope: RequestScope, + io: { logger: ProvenanceLogger; readSource: SourceDocumentReader } ) { - this.payload = payload; this.store = store; this.schemaMap = schemaMap; this.scope = scope; + this.logger = io.logger; + this.readSource = io.readSource; } /** - * Hash the source the translation was made from — the baseline staleness is later measured against. - * - * Ordering used to matter: the pipeline wrote into object-valued leaves it shared with the caller's - * source, so hashing afterwards captured the translation and reported every fresh translation as - * stale. It now detaches those leaves, so this may be called on either side of the pipeline. + * Hash the source a translation is made from — the baseline staleness is measured against. * - * Returns `null` on any failure (no schema, hashing error) so provenance is skipped, not the translation. + * 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. */ captureFingerprint( collection: CollectionSlug, sourceData: Record - ): string | null { + ): FieldFingerprints | null { const schema = this.schemaMap.get(collection); if (!schema) return null; try { - return computeSourceFingerprint(sourceData, schema); + return computeFieldFingerprints(sourceData, schema); } catch (error) { - this.payload.logger.error({ + this.logger.error({ err: error, collection, msg: "translator: failed to fingerprint source for provenance", @@ -78,24 +83,42 @@ export class ProvenanceService { } } + /** + * Write the receipt for a finished translation. + * + * The map is **merged**, not replaced: a skipped leaf keeps the previous receipt's claim, or is + * marked seen-but-not-ours. An address the document no longer has is dropped, retiring the field. + * + * @param currentFields - Every translatable leaf of the source as it stands now. + * @param translatedAddresses - The leaves this run actually sent for translation. + */ async record( key: ProvenanceKey & { sourceLocale: string }, - sourceFingerprint: string + currentFields: FieldFingerprints, + translatedAddresses: readonly string[] ): Promise { await swallowOrThrow( this.scope, - () => - this.store.upsert({ + async () => { + const previous = await this.readFingerprintOrThrow(key); + const translated = new Set(translatedAddresses); + const hashes: Record = {}; + for (const [address, hash] of Object.entries(currentFields)) { + hashes[address] = translated.has(address) ? hash : previousClaim(previous, address); + } + + await this.store.upsert({ collectionSlug: key.collectionSlug, documentId: key.documentId, targetLocale: key.targetLocale, sourceLocale: key.sourceLocale, - sourceFingerprint, + sourceFingerprint: { kind: "fields", hashes }, translatedAt: new Date().toISOString(), dismissedFingerprint: null, - }), + }); + }, (error) => - this.payload.logger.error({ + this.logger.error({ err: error, collection: key.collectionSlug, documentId: key.documentId, @@ -106,6 +129,28 @@ export class ProvenanceService { ); } + /** 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", + }) + ); + return read ?? null; + } + + 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 @@ -129,7 +174,7 @@ export class ProvenanceService { this.scope, () => currentFingerprint(record.sourceLocale), (error) => - this.payload.logger.error({ + this.logger.error({ err: error, collection, documentId, @@ -171,27 +216,20 @@ export class ProvenanceService { const fingerprint = await currentFingerprint(record.sourceLocale); if (fingerprint === null) return; - await this.store.dismiss(key, fingerprint); + await this.store.dismiss(key, { kind: "fields", hashes: fingerprint.fields }); } - /** - * Recompute the current source fingerprint the same way the write path does (shared fetch shape + - * hash). Cached per source locale so a document translated from one source into N locales fetches - * the source once. Yields `null` when the source is not readable by `user` — that locale is then - * simply not reported. - */ private makeCurrentFingerprint( collection: CollectionSlug, documentId: string, schema: FieldLike[], user: Record | null ) { - const cache = new Map(); - return async (sourceLocale: string): Promise => { + const cache = new Map(); + return async (sourceLocale: string): Promise => { const cached = cache.get(sourceLocale); if (cached !== undefined) return cached; - const sourceData = await fetchSourceDocument({ - payload: this.payload, + const sourceData = await this.readSource({ collection, id: documentId, locale: sourceLocale, @@ -199,7 +237,10 @@ export class ProvenanceService { }); if (!sourceData) return null; - const fingerprint = computeSourceFingerprint(sourceData, schema); + const fingerprint: CurrentFingerprint = { + document: computeSourceFingerprint(sourceData, schema), + fields: computeFieldFingerprints(sourceData, schema), + }; cache.set(sourceLocale, fingerprint); return fingerprint; }; diff --git a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.shapes.ts b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.shapes.ts index 595e2fe83..851add955 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.shapes.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/Provenance.shapes.ts @@ -1,14 +1,9 @@ -import type { CollectionAfterDeleteHook } from "payload"; +import type { CollectionAfterDeleteHook, CollectionSlug } from "payload"; /** - * The minimal slice of a Payload collection that provenance's config-time wiring reads and mutates: - * its `slug`, the sidecar `custom` marker, and the `afterDelete` hook slot. A real `CollectionConfig` - * is **structurally assignable** to this — call sites pass the live collection with no adapter, and a - * test passes a plain `{ slug: "posts" }` literal. Keeps `injectProvenanceCleanup` / - * `ensureProvenanceCollectionRegistered` off the god-`Config`/`CollectionConfig` types. - * - * The only Payload type imported here is `CollectionAfterDeleteHook` — a framework callback contract - * that legitimately stays framework-typed. + * The slice of a Payload collection provenance's config-time wiring reads and mutates. A real + * `CollectionConfig` is structurally assignable, so call sites pass the live collection and tests + * pass `{ slug: "posts" }`. */ export type ManagedCollectionEntry = { slug: string; @@ -24,3 +19,19 @@ export type ManagedCollectionEntry = { export type ManagedCollectionsConfig = { collections?: ManagedCollectionEntry[]; }; + +export type ProvenanceLogger = { + error(details: Record): void; +}; + +/** + * Reading the source document a translation was made from, bound to a Payload instance by the wiring. + * + * `null` means not available to this caller, and deliberately does not distinguish refused from absent. + */ +export type SourceDocumentReader = (query: { + collection: CollectionSlug; + id: string; + locale: string; + user: Record | null; +}) => Promise | null>; 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 0ff4690f7..007bacb65 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,10 +5,14 @@ import { freshReq } from "../../shared/payload/RequestScope.shapes.js"; import type { ProvenanceKey, ProvenanceStore, - TranslationProvenanceRecord, + SourceFingerprint, + ProvenanceReceipt, +} from "../../../core/domain/provenance/index.js"; +import { + parseSourceFingerprint, + serializeSourceFingerprint, } from "../../../core/domain/provenance/index.js"; -/** Builds a provenance store bound to a Payload instance; absent when provenance is disabled. */ export type ProvenanceStoreFactory = (payload: Payload, scope?: RequestScope) => ProvenanceStore; interface ProvenanceDoc extends Record { @@ -31,18 +35,33 @@ function keyWhere(key: ProvenanceKey): Where { }; } -function toRecord(doc: ProvenanceDoc): TranslationProvenanceRecord { +const readFingerprint = (stored: unknown): SourceFingerprint | 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), documentId: String(doc.documentId), targetLocale: String(doc.targetLocale), sourceLocale: String(doc.sourceLocale), - sourceFingerprint: String(doc.sourceFingerprint), - // `translatedAt` is backed by a `date` field, which Payload may hand back as a Date; normalize to - // ISO-8601 so the stored contract holds and #50's fingerprint comparison stays format-stable. + sourceFingerprint: readFingerprint(doc.sourceFingerprint), translatedAt: new Date(doc.translatedAt as string | number | Date).toISOString(), + dismissedFingerprint: readFingerprint(doc.dismissedFingerprint), + }; +} + +function toStoredData(record: ProvenanceReceipt): Record { + return { + ...record, + sourceFingerprint: + record.sourceFingerprint === null + ? null + : serializeSourceFingerprint(record.sourceFingerprint), dismissedFingerprint: - doc.dismissedFingerprint == null ? null : String(doc.dismissedFingerprint), + record.dismissedFingerprint === null + ? null + : serializeSourceFingerprint(record.dismissedFingerprint), }; } @@ -69,11 +88,12 @@ export class PayloadProvenanceStore implements ProvenanceStore { return freshReq(this.scope); } - async upsert(record: TranslationProvenanceRecord): Promise { + async upsert(record: ProvenanceReceipt): Promise { + const data = toStoredData(record); const existing = await this.findDoc(record); if (existing === null) { try { - await this.payload.create({ req: this.req(), collection: this.collection, data: record }); + await this.payload.create({ req: this.req(), collection: this.collection, data }); } catch (error) { const raceWinner = await this.findDoc(record); if (raceWinner === null) throw error; @@ -81,7 +101,7 @@ export class PayloadProvenanceStore implements ProvenanceStore { req: this.req(), collection: this.collection, id: raceWinner.id, - data: record, + data, }); } } else { @@ -89,20 +109,17 @@ export class PayloadProvenanceStore implements ProvenanceStore { req: this.req(), collection: this.collection, id: existing.id, - data: record, + data, }); } } - async find(key: ProvenanceKey): Promise { + async find(key: ProvenanceKey): Promise { const doc = await this.findDoc(key); return doc === null ? null : toRecord(doc); } - async findByDocument( - collectionSlug: string, - documentId: string - ): Promise { + async findByDocument(collectionSlug: string, documentId: string): Promise { const result = await this.payload.find({ req: this.req(), collection: this.collection, @@ -113,14 +130,14 @@ export class PayloadProvenanceStore implements ProvenanceStore { return (result.docs as ProvenanceDoc[]).map(toRecord); } - async dismiss(key: ProvenanceKey, dismissedFingerprint: string): Promise { + async dismiss(key: ProvenanceKey, dismissedFingerprint: SourceFingerprint): 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 }, + data: { dismissedFingerprint: serializeSourceFingerprint(dismissedFingerprint) }, }); } 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 effc819d3..dde28442b 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,10 +1,14 @@ +import type { Payload } from "payload"; + import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { ConfigModifier } from "../../../types/ConfigModifier.js"; import { ProvenanceService } from "./Provenance.service.js"; -import type { ProvenanceServiceFactory } from "./Provenance.service.js"; import { PayloadProvenanceStore } from "./Provenance.store.js"; import type { ProvenanceStoreFactory } from "./Provenance.store.js"; +import { fetchSourceDocument } from "../../shared/payload/sourceDocument.js"; +import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; +import type { SourceDocumentReader } from "./Provenance.shapes.js"; import { DEFAULT_PROVENANCE_SLUG, ensureProvenanceCollectionRegistered, @@ -23,7 +27,6 @@ export type ProvenanceOption = boolean | { slug?: string } | undefined; function resolveProvenanceSlug(option: ProvenanceOption): string | null { if (!option) return null; if (option === true) return DEFAULT_PROVENANCE_SLUG; - // `||` (not `??`) so an empty/blank slug falls back to the default instead of silently disabling. return option.slug || DEFAULT_PROVENANCE_SLUG; } @@ -40,6 +43,27 @@ export type ProvenanceModule = { const NOOP: ConfigModifier = (config) => config; +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]) => + fetchSourceDocument({ + payload, + collection, + id, + locale, + user, + scope: OUTSIDE_THE_CALLERS_TRANSACTION, + }), +}); + +export type ProvenanceServiceFactory = ( + payload: Payload, + scope?: RequestScope +) => ProvenanceService; + /** * Turn the opt-in `provenance` option into a self-contained {@link ProvenanceModule}. This is the one * place provenance's config-time wiring lives — `plugin.ts` only calls `configureProvenance(...)` and @@ -54,16 +78,13 @@ export function configureProvenance( const storeFactory: ProvenanceStoreFactory = (payload, scope) => new PayloadProvenanceStore(payload, slug, scope); - const serviceFactory: ProvenanceServiceFactory = (payload, scope) => - new ProvenanceService(payload, storeFactory(payload, scope), schemaMap, scope); + + const serviceFactory: ProvenanceServiceFactory = (payload, scope = {}) => + new ProvenanceService(storeFactory(payload, scope), schemaMap, scope, provenanceIo(payload)); const configure = (managedSlugs: Set): ConfigModifier => (config) => { - // `config` infers as Payload's `Config` from the ConfigModifier return type, so this leaf never - // names the god-type — it only reads/mutates through narrow helpers below. - // Fail fast on a slug collision with a consumer collection (ignoring our own sidecar on a repeat - // init, so an idempotent re-run doesn't false-positive). assertProvenanceSlugFree( slug, (config.collections ?? []).filter((collection) => !isProvenanceCollection(collection)) 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 dcf9f8f40..461bca607 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 @@ -1,11 +1,11 @@ import { TranslatorBug } from "../../../../core/errors/index.js"; import { describe, it, expect, vi } from "vitest"; -import type { Field, Payload } from "payload"; +import type { Field } from "payload"; import { APIError } from "payload"; import type { ProvenanceStore, - TranslationProvenanceRecord, + ProvenanceReceipt, } from "../../../../core/domain/provenance/index.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; @@ -27,19 +27,19 @@ function makeStore(overrides: Partial = {}): ProvenanceStore { }; } -function makePayload(findByID: (args: { locale: string }) => Promise): Payload { +function makeIo(read: (args: { locale: string }) => Promise | null>) { return { - findByID: vi.fn(findByID), logger: { error: vi.fn() }, - } as unknown as Payload; + readSource: vi.fn(({ locale }: { locale: string }) => read({ locale })), + }; } -const record = (over: Partial = {}): TranslationProvenanceRecord => ({ +const record = (over: Partial = {}): ProvenanceReceipt => ({ collectionSlug: COLLECTION, documentId: "1", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: "x", + sourceFingerprint: { kind: "fields", hashes: { title: "x" } }, translatedAt: "2026-07-15T00:00:00.000Z", dismissedFingerprint: null, ...over, @@ -48,45 +48,54 @@ const record = (over: Partial = {}): TranslationPro describe("ProvenanceService", () => { it("captureFingerprint returns null for a collection with no schema", () => { const service = new ProvenanceService( - makePayload(async () => sourceDoc), makeStore(), - schemaMap + schemaMap, + {}, + makeIo(async () => sourceDoc) ); expect(service.captureFingerprint("unknown", sourceDoc)).toBeNull(); }); it("write capture and read recompute hash identically — the one-owner invariant", async () => { - // The fingerprint the write path would store for the pristine source... const captureService = new ProvenanceService( - makePayload(async () => sourceDoc), makeStore(), - schemaMap + schemaMap, + {}, + makeIo(async () => sourceDoc) ); const writeFingerprint = captureService.captureFingerprint(COLLECTION, sourceDoc); - expect(typeof writeFingerprint).toBe("string"); + expect(writeFingerprint).toEqual(expect.objectContaining({ title: expect.any(String) })); - // ...must make the read path report NOT stale when the live source is unchanged. const freshStore = makeStore({ - findByDocument: vi.fn().mockResolvedValue([record({ sourceFingerprint: writeFingerprint! })]), + findByDocument: vi + .fn() + .mockResolvedValue([ + record({ sourceFingerprint: { kind: "fields", hashes: writeFingerprint! } }), + ]), }); const freshService = new ProvenanceService( - makePayload(async () => sourceDoc), freshStore, - schemaMap + schemaMap, + {}, + makeIo(async () => sourceDoc) ); const fresh = await freshService.getStaleness(COLLECTION, "1"); expect(fresh).toEqual([ { target_lng: "de", source_lng: "en", is_stale: false, translated_at: record().translatedAt }, ]); - // ...and stale once the live source drifts. const driftStore = makeStore({ - findByDocument: vi.fn().mockResolvedValue([record({ sourceFingerprint: writeFingerprint! })]), + findByDocument: vi + .fn() + .mockResolvedValue([ + record({ sourceFingerprint: { kind: "fields", hashes: writeFingerprint! } }), + ]), }); const driftService = new ProvenanceService( - makePayload(async () => ({ id: "1", title: "Changed" })), driftStore, - schemaMap + schemaMap, + {}, + makeIo(async () => ({ id: "1", title: "Changed" })) ); const drifted = await driftService.getStaleness(COLLECTION, "1"); expect(drifted[0].is_stale).toBe(true); @@ -101,10 +110,10 @@ describe("ProvenanceService", () => { record({ targetLocale: "fr", sourceLocale: "it" }), ]), }); - const payload = makePayload(async ({ locale }) => + const io = makeIo(async ({ locale }) => locale === "en" ? Promise.reject(new APIError("relation does not exist")) : sourceDoc ); - const service = new ProvenanceService(payload, store, schemaMap); + const service = new ProvenanceService(store, schemaMap, {}, io); const locales = await service.getStaleness(COLLECTION, "1"); @@ -112,16 +121,16 @@ describe("ProvenanceService", () => { locales.map((l) => l.target_lng), "no transaction carried this read, so one unreadable locale must not cost the others" ).toEqual(["fr"]); - expect(payload.logger.error as ReturnType).toHaveBeenCalled(); + expect(io.logger.error as ReturnType).toHaveBeenCalled(); }); it("getStaleness lets a foreign failure out when the caller is in a transaction", async () => { const fromPayload = new APIError("relation does not exist"); const store = makeStore({ findByDocument: vi.fn().mockResolvedValue([record()]) }); - const payload = makePayload(async () => { + const io = makeIo(async () => { throw fromPayload; }); - const service = new ProvenanceService(payload, store, schemaMap, { transactionID: "tx-1" }); + const service = new ProvenanceService(store, schemaMap, { transactionID: "tx-1" }, io); await expect( service.getStaleness(COLLECTION, "1"), @@ -131,65 +140,69 @@ describe("ProvenanceService", () => { it("getStaleness swallows one of ours even inside the caller's transaction", async () => { const store = makeStore({ findByDocument: vi.fn().mockResolvedValue([record()]) }); - const payload = makePayload(async () => { + const io = makeIo(async () => { throw new TranslatorBug("fingerprint blew up"); }); - const service = new ProvenanceService(payload, store, schemaMap, { transactionID: "tx-1" }); + const service = new ProvenanceService(store, schemaMap, { transactionID: "tx-1" }, io); await expect(service.getStaleness(COLLECTION, "1")).resolves.toEqual([]); }); it("record is best-effort — a store failure of ours is caught and logged, not thrown", async () => { - const payload = makePayload(async () => sourceDoc); + const io = makeIo(async () => sourceDoc); const store = makeStore({ upsert: vi.fn().mockRejectedValue(new TranslatorBug("table down")) }); - const service = new ProvenanceService(payload, store, schemaMap); + const service = new ProvenanceService(store, schemaMap, {}, io); await expect( service.record( { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de", sourceLocale: "en" }, - "fp" + { title: "fp" }, + ["title"] ) ).resolves.toBeUndefined(); - expect(payload.logger.error as ReturnType).toHaveBeenCalled(); + expect(io.logger.error as ReturnType).toHaveBeenCalled(); }); it("record swallows a Payload failure when there is no transaction to lose", async () => { - const payload = makePayload(async () => sourceDoc); + const io = makeIo(async () => sourceDoc); const store = makeStore({ upsert: vi.fn().mockRejectedValue(new APIError("rejected")) }); - const service = new ProvenanceService(payload, store, schemaMap); + const service = new ProvenanceService(store, schemaMap, {}, io); await expect( service.record( { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de", sourceLocale: "en" }, - "fp" + { title: "fp" }, + ["title"] ), "no transaction carried the caller's work, so a lost receipt must not cost them anything" ).resolves.toBeUndefined(); - expect(payload.logger.error as ReturnType).toHaveBeenCalled(); + expect(io.logger.error as ReturnType).toHaveBeenCalled(); }); it("record rethrows a Payload failure raised inside the caller's transaction", async () => { - const payload = makePayload(async () => sourceDoc); + const io = makeIo(async () => sourceDoc); const store = makeStore({ upsert: vi.fn().mockRejectedValue(new APIError("rejected")) }); - const service = new ProvenanceService(payload, store, schemaMap, { transactionID: "tx-1" }); + const service = new ProvenanceService(store, schemaMap, { transactionID: "tx-1" }, io); await expect( service.record( { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de", sourceLocale: "en" }, - "fp" + { title: "fp" }, + ["title"] ) ).rejects.toThrow(APIError); }); it("record stays best-effort inside a transaction when the failure reached no Payload operation", async () => { - const payload = makePayload(async () => sourceDoc); + const io = makeIo(async () => sourceDoc); const store = makeStore({ upsert: vi.fn().mockRejectedValue(new TranslatorBug("table down")) }); - const service = new ProvenanceService(payload, store, schemaMap, { transactionID: "tx-1" }); + const service = new ProvenanceService(store, schemaMap, { transactionID: "tx-1" }, io); await expect( service.record( { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de", sourceLocale: "en" }, - "fp" + { title: "fp" }, + ["title"] ) ).resolves.toBeUndefined(); }); @@ -198,9 +211,10 @@ describe("ProvenanceService", () => { const dismiss = vi.fn().mockResolvedValue(undefined); const store = makeStore({ find: vi.fn().mockResolvedValue(record()), dismiss }); const service = new ProvenanceService( - makePayload(async () => sourceDoc), store, - schemaMap + schemaMap, + {}, + makeIo(async () => sourceDoc) ); await service.dismiss({ collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }); @@ -208,6 +222,219 @@ describe("ProvenanceService", () => { expect(dismiss).toHaveBeenCalledTimes(1); const [key, fingerprint] = dismiss.mock.calls[0]; expect(key).toEqual({ collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }); - expect(typeof fingerprint).toBe("string"); + expect(fingerprint, "a later source edit must be able to move past the dismissal").toEqual({ + kind: "fields", + hashes: service.captureFingerprint(COLLECTION, sourceDoc), + }); + }); + + describe("record merges rather than replaces — a partial run must not claim the whole document", () => { + const key = { + collectionSlug: COLLECTION, + documentId: "1", + targetLocale: "de", + sourceLocale: "en", + }; + const storedWith = (hashes: Record) => + makeStore({ + find: vi.fn().mockResolvedValue(record({ sourceFingerprint: { kind: "fields", hashes } })), + upsert: vi.fn().mockResolvedValue(undefined), + }); + + it("takes the current hash for a leaf it translated", async () => { + const store = storedWith({ title: "old", body: "old-body" }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => sourceDoc) + ); + + await service.record(key, { title: "new", body: "old-body" }, ["title"]); + + expect(store.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + sourceFingerprint: { kind: "fields", hashes: { title: "new", body: "old-body" } }, + }) + ); + }); + + it("keeps the stored hash for a leaf it skipped, even though the source moved", async () => { + const store = storedWith({ title: "old", body: "old-body" }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => sourceDoc) + ); + + await service.record(key, { title: "new", body: "moved" }, ["title"]); + + expect(store.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + sourceFingerprint: { kind: "fields", hashes: { title: "new", body: "old-body" } }, + }) + ); + }); + + it("drops an address the document no longer has", async () => { + const store = storedWith({ title: "old", gone: "stale" }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => sourceDoc) + ); + + await service.record(key, { title: "new" }, ["title"]); + + expect(store.upsert).toHaveBeenCalledWith( + expect.objectContaining({ sourceFingerprint: { kind: "fields", hashes: { title: "new" } } }) + ); + }); + + it("records only what it translated when the receipt predates per-field fingerprints", async () => { + const store = makeStore({ + find: vi + .fn() + .mockResolvedValue( + record({ sourceFingerprint: { kind: "document", hash: "a".repeat(64) } }) + ), + upsert: vi.fn().mockResolvedValue(undefined), + }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => sourceDoc) + ); + + await service.record(key, { title: "new", body: "untouched" }, ["title"]); + + expect(store.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + sourceFingerprint: { kind: "fields", hashes: { title: "new", body: null } }, + }) + ); + }); + + // Treating a read failure as "no receipt" would mark every leaf this run did not translate as + // not-ours, erasing what earlier runs recorded. + it("writes nothing when it cannot read the receipt it must merge into", async () => { + const upsert = vi.fn().mockResolvedValue(undefined); + const store = makeStore({ + find: vi.fn().mockRejectedValue(new TranslatorBug("table down")), + upsert, + }); + const io = makeIo(async () => sourceDoc); + const service = new ProvenanceService(store, schemaMap, {}, io); + + await expect( + service.record(key, { title: "new", body: "old" }, ["title"]) + ).resolves.toBeUndefined(); + + expect( + upsert, + "a half-known receipt is worse than the one already on disk" + ).not.toHaveBeenCalled(); + expect(io.logger.error as ReturnType).toHaveBeenCalled(); + }); + + it("lets a read failure out when the caller's transaction is at stake", async () => { + const store = makeStore({ + find: vi.fn().mockRejectedValue(new APIError("rejected")), + upsert: vi.fn().mockResolvedValue(undefined), + }); + const service = new ProvenanceService( + store, + schemaMap, + { transactionID: "tx-1" }, + makeIo(async () => sourceDoc) + ); + + await expect(service.record(key, { title: "new" }, ["title"])).rejects.toThrow(APIError); + }); + + it("marks a leaf it saw and did not translate, rather than leaving it out", async () => { + const store = makeStore({ + find: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockResolvedValue(undefined), + }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => sourceDoc) + ); + + await service.record(key, { title: "new", body: "untouched" }, ["title"]); + + expect(store.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + sourceFingerprint: { kind: "fields", hashes: { title: "new", body: null } }, + }) + ); + }); + + it("records only what it translated when there is no receipt at all", async () => { + const store = makeStore({ + find: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockResolvedValue(undefined), + }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => sourceDoc) + ); + + await service.record(key, { title: "new", body: "untouched" }, ["title"]); + + expect(store.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + sourceFingerprint: { kind: "fields", hashes: { title: "new", body: null } }, + }) + ); + }); + }); + + describe("lastTranslatedFrom — the receipt, read best-effort", () => { + const key = { collectionSlug: COLLECTION, documentId: "1", targetLocale: "de" }; + const stored = { kind: "fields", hashes: { title: "abc" } } as const; + + it("hands back the receipt when it can be read", async () => { + const store = makeStore({ + find: vi.fn().mockResolvedValue(record({ sourceFingerprint: stored })), + }); + const service = new ProvenanceService( + store, + schemaMap, + {}, + makeIo(async () => sourceDoc) + ); + + expect(await service.lastTranslatedFrom(key)).toEqual(stored); + }); + + it("hands back nothing, and logs, when the sidecar cannot be reached", async () => { + const io = makeIo(async () => sourceDoc); + const store = makeStore({ find: vi.fn().mockRejectedValue(new TranslatorBug("table down")) }); + const service = new ProvenanceService(store, schemaMap, {}, io); + + await expect(service.lastTranslatedFrom(key)).resolves.toBeNull(); + expect(io.logger.error as ReturnType).toHaveBeenCalled(); + }); + + it("lets a failure out when the caller's transaction is at stake", async () => { + const store = makeStore({ find: vi.fn().mockRejectedValue(new APIError("rejected")) }); + const service = new ProvenanceService( + store, + schemaMap, + { transactionID: "tx-1" }, + makeIo(async () => sourceDoc) + ); + + await expect(service.lastTranslatedFrom(key)).rejects.toThrow(APIError); + }); }); }); 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 882d30502..77a5dc258 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 @@ -1,20 +1,33 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload } from "payload"; -import type { TranslationProvenanceRecord } from "../../../../core/domain/provenance/index.js"; +import type { ProvenanceReceipt } from "../../../../core/domain/provenance/index.js"; import { PayloadProvenanceStore } from "../Provenance.store.js"; const SLUG = "translator-provenance"; -const record: TranslationProvenanceRecord = { +const HASHES = { title: "3a7f1c", body: "91ce04" }; +const SERIALIZED = JSON.stringify(HASHES); +const DISMISSED_HASHES = { title: "3a7f1c", body: "ffffff" }; +const SERIALIZED_DISMISSED = JSON.stringify(DISMISSED_HASHES); +const SHA256_HEX_LENGTH = 64; +const LEGACY = "a".repeat(SHA256_HEX_LENGTH); + +const record: ProvenanceReceipt = { collectionSlug: "posts", documentId: "doc-1", targetLocale: "de", sourceLocale: "en", - sourceFingerprint: "fp-abc", + sourceFingerprint: { kind: "fields", hashes: HASHES }, translatedAt: "2026-07-02T00:00:00.000Z", dismissedFingerprint: null, }; +const row = { + ...record, + sourceFingerprint: SERIALIZED, + dismissedFingerprint: null as string | null, +}; + describe("PayloadProvenanceStore", () => { let payload: Payload; let store: PayloadProvenanceStore; @@ -38,19 +51,22 @@ describe("PayloadProvenanceStore", () => { setFound([]); await store.upsert(record); expect(payload.create).toHaveBeenCalledWith( - expect.objectContaining({ collection: SLUG, data: expect.objectContaining(record) }) + expect.objectContaining({ + collection: SLUG, + data: expect.objectContaining({ ...record, sourceFingerprint: SERIALIZED }), + }) ); expect(payload.update).not.toHaveBeenCalled(); }); it("updates the existing record in place, never duplicating", async () => { - setFound([{ id: 7, ...record }]); - await store.upsert({ ...record, sourceFingerprint: "fp-new" }); + setFound([{ id: 7, ...row }]); + await store.upsert({ ...record, sourceFingerprint: { kind: "document", hash: LEGACY } }); expect(payload.update).toHaveBeenCalledWith( expect.objectContaining({ collection: SLUG, id: 7, - data: expect.objectContaining({ sourceFingerprint: "fp-new" }), + data: expect.objectContaining({ sourceFingerprint: LEGACY }), }) ); expect(payload.create).not.toHaveBeenCalled(); @@ -76,7 +92,7 @@ describe("PayloadProvenanceStore", () => { it("falls back to update when a concurrent writer wins the create race", async () => { const findMock = payload.find as ReturnType; findMock.mockResolvedValueOnce({ docs: [] }).mockResolvedValueOnce({ - docs: [{ id: 9, ...record }], + docs: [{ id: 9, ...row }], }); (payload.create as ReturnType).mockRejectedValueOnce( new Error("unique constraint violation") @@ -85,7 +101,11 @@ describe("PayloadProvenanceStore", () => { await expect(store.upsert(record)).resolves.toBeUndefined(); expect(payload.update).toHaveBeenCalledWith( - expect.objectContaining({ collection: SLUG, id: 9, data: expect.objectContaining(record) }) + expect.objectContaining({ + collection: SLUG, + id: 9, + data: expect.objectContaining({ ...record, sourceFingerprint: SERIALIZED }), + }) ); }); @@ -102,7 +122,7 @@ describe("PayloadProvenanceStore", () => { describe("find", () => { it("returns the record for the key", async () => { - setFound([{ id: 7, ...record }]); + setFound([{ id: 7, ...row }]); const result = await store.find({ collectionSlug: "posts", documentId: "doc-1", @@ -124,7 +144,7 @@ describe("PayloadProvenanceStore", () => { it("normalizes a Date translatedAt to an ISO-8601 string", async () => { // Payload's `date` field may hand back a Date; #50's fingerprint comparison needs a stable ISO // string, so toRecord must convert it. - setFound([{ id: 7, ...record, translatedAt: new Date("2026-07-02T00:00:00.000Z") }]); + setFound([{ id: 7, ...row, translatedAt: new Date("2026-07-02T00:00:00.000Z") }]); const result = await store.find({ collectionSlug: "posts", documentId: "doc-1", @@ -133,14 +153,37 @@ describe("PayloadProvenanceStore", () => { expect(result?.translatedAt).toBe("2026-07-02T00:00:00.000Z"); }); - it("preserves a non-null dismissedFingerprint as a string", async () => { - setFound([{ id: 7, ...record, dismissedFingerprint: "fp-dismissed" }]); + it("parses a non-null dismissedFingerprint into the union", async () => { + setFound([{ id: 7, ...row, dismissedFingerprint: SERIALIZED_DISMISSED }]); const result = await store.find({ collectionSlug: "posts", documentId: "doc-1", targetLocale: "de", }); - expect(result?.dismissedFingerprint).toBe("fp-dismissed"); + expect(result?.dismissedFingerprint).toEqual({ kind: "fields", hashes: DISMISSED_HASHES }); + }); + + 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 }); + }); + + 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", + }); }); }); @@ -184,13 +227,13 @@ describe("PayloadProvenanceStore", () => { it("maps every found doc through toRecord", async () => { setFound([ - { id: 1, ...record }, + { id: 1, ...row }, { id: 2, - ...record, + ...row, targetLocale: "fr", translatedAt: new Date("2026-07-02T00:00:00.000Z"), - dismissedFingerprint: "fp-dismissed", + dismissedFingerprint: SERIALIZED_DISMISSED, }, ]); const result = await store.findByDocument("posts", "doc-1"); @@ -200,7 +243,7 @@ describe("PayloadProvenanceStore", () => { ...record, targetLocale: "fr", translatedAt: "2026-07-02T00:00:00.000Z", - dismissedFingerprint: "fp-dismissed", + dismissedFingerprint: { kind: "fields", hashes: DISMISSED_HASHES }, }, ]); }); @@ -215,20 +258,20 @@ describe("PayloadProvenanceStore", () => { const key = { collectionSlug: "posts", documentId: "doc-1", targetLocale: "de" }; it("updates dismissedFingerprint on the matched record", async () => { - setFound([{ id: 7, ...record }]); - await store.dismiss(key, "fp-current"); + 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: "fp-current" }, + data: { dismissedFingerprint: SERIALIZED_DISMISSED }, }) ); }); it("matches the record by the composite key", async () => { - setFound([{ id: 7, ...record }]); - await store.dismiss(key, "fp-current"); + setFound([{ id: 7, ...row }]); + await store.dismiss(key, { kind: "fields", hashes: DISMISSED_HASHES }); expect(payload.find).toHaveBeenCalledWith( expect.objectContaining({ where: { @@ -244,7 +287,7 @@ describe("PayloadProvenanceStore", () => { it("is a no-op when no record exists for the key", async () => { setFound([]); - await store.dismiss(key, "fp-current"); + 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/index.ts b/packages/payload-plugin-translator/src/server/modules/provenance/index.ts index f4bf44ab8..01c8f5231 100644 --- a/packages/payload-plugin-translator/src/server/modules/provenance/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/provenance/index.ts @@ -10,6 +10,10 @@ 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 { ProvenanceServiceFactory, StalenessLocale } from "./Provenance.service.js"; -export { configureProvenance } from "./Provenance.wiring.js"; -export type { ProvenanceModule, ProvenanceOption } from "./Provenance.wiring.js"; +export type { StalenessLocale } from "./Provenance.service.js"; +export { configureProvenance, provenanceIo } from "./Provenance.wiring.js"; +export type { + ProvenanceModule, + ProvenanceOption, + ProvenanceServiceFactory, +} from "./Provenance.wiring.js";