Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1a143e7
feat: the pipeline is handed the leaves to translate
SearheiParkhamchuk Oct 25, 2026
a13052d
feat!: a request names the fields to translate, not a strategy
SearheiParkhamchuk Oct 25, 2026
a8cbfdf
feat: the run carries one list of filters and one context
SearheiParkhamchuk Oct 25, 2026
7195218
feat: provenance answers the drift rule and keeps unsent claims
SearheiParkhamchuk Oct 25, 2026
1fa6e5d
feat!: auto-translate loses the strategy option
SearheiParkhamchuk Oct 25, 2026
1fc96d4
feat: the admin forms stop asking for a strategy
SearheiParkhamchuk Oct 25, 2026
bfe9eff
test: the integration suites follow the fields
SearheiParkhamchuk Oct 25, 2026
c792ac3
docs: record the strategy's removal
SearheiParkhamchuk Oct 25, 2026
685a999
refactor: the run is a function, not a handler class
SearheiParkhamchuk Oct 25, 2026
bc761f6
refactor: the permission asker moves to the shared layer
SearheiParkhamchuk Oct 25, 2026
7773aa3
feat: two routes ask before they read or spend
SearheiParkhamchuk Oct 25, 2026
1d9fd55
feat: the guard is asked what the caller wants to do
SearheiParkhamchuk Oct 25, 2026
442d480
feat: enqueue asks whether the caller may change each document
SearheiParkhamchuk Oct 25, 2026
d15b0d5
feat: a job is resolved to its document before anyone may touch it
SearheiParkhamchuk Oct 25, 2026
8d6b18b
feat!: the queue takes only work the rules were asked about
SearheiParkhamchuk Oct 25, 2026
53d3d08
feat: an actor we cannot name stops being read as no actor
SearheiParkhamchuk Oct 25, 2026
ab0cf12
feat: a chain module can no longer write the document being translated
SearheiParkhamchuk Oct 25, 2026
2b6c3c8
docs: a guard author can read what the asks cost them
SearheiParkhamchuk Oct 25, 2026
93dfeb8
feat!: a runner must be able to place its own handles
SearheiParkhamchuk Oct 26, 2026
7904a46
feat: the conformance suite ships, so a runner author can run it
SearheiParkhamchuk Oct 26, 2026
4b00f6e
fix: the rules are asked about a document, not about one of its langu…
SearheiParkhamchuk Oct 26, 2026
40bd9ce
fix: an actor the project cannot name is refused before anything is a…
SearheiParkhamchuk Oct 26, 2026
2baa4e4
fix: a read that names no reader no longer compiles, and the error wr…
SearheiParkhamchuk Oct 26, 2026
d0547f3
test: the asks the suite was not making
SearheiParkhamchuk Oct 26, 2026
5b6130a
docs: the comments that said something untrue
SearheiParkhamchuk Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion apps/dev/docs/multi-db-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ bun run verify:provenance:postgres
```

`scripts/verify-provenance.ts` boots Payload against the active adapter and asserts, via the local API
(no OpenAI call): the sidecar collection exists, `dismissedFingerprint: null` round-trips, the composite
(no OpenAI call): the sidecar collection exists, a receipt round-trips, the composite
key `(collectionSlug, documentId, targetLocale)` is unique, and `deleteByDocument` (the query the
`afterDelete` hook runs) clears a document's rows across all locales. The SQLite run uses a throwaway
`.verify-sqlite.db` (push-created, gitignored) so it never touches your working `dev.db`.
Expand Down
9 changes: 2 additions & 7 deletions apps/dev/scripts/verify-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ try {
process.exit(1);
}

// 2. dismissedFingerprint: null round-trips (the deferred cross-DB null check).
// 2. A receipt round-trips through the active adapter.
const created = await payload.create({
collection: SLUG,
data: {
Expand All @@ -37,14 +37,11 @@ const created = await payload.create({
sourceLocale: "en",
sourceFingerprint: "fp-verify",
translatedAt: new Date().toISOString(),
dismissedFingerprint: null,
},
});
const reread = await payload.findByID({ collection: SLUG, id: created.id });
if (reread.dismissedFingerprint === null) ok("dismissedFingerprint: null round-trips");
else fail(`dismissedFingerprint expected null, got ${JSON.stringify(reread.dismissedFingerprint)}`);
if (reread.sourceFingerprint === "fp-verify" && reread.targetLocale === "de")
ok("other fields round-trip");
ok("receipt fields round-trip");
else fail("field round-trip mismatch");

// 3. Composite key (collectionSlug, documentId, targetLocale) uniqueness. Hard check on SQL; on Mongo
Expand All @@ -62,7 +59,6 @@ try {
sourceLocale: "en",
sourceFingerprint: "fp-dup",
translatedAt: new Date().toISOString(),
dismissedFingerprint: null,
},
});
duplicateAllowed = true;
Expand Down Expand Up @@ -92,7 +88,6 @@ for (const locale of ["de", "fr"]) {
sourceLocale: "en",
sourceFingerprint: "fp-doc",
translatedAt: new Date().toISOString(),
dismissedFingerprint: null,
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,25 @@ const jobsFor = async (documentId: string) => {
);
};

const enqueueAs = async (id: string, user: { id: string; collection: string } | null) => {
type Assignment = { handle: string };

const enqueueAs = async (
id: string,
user: { id: string; collection: string } | null
): Promise<Assignment[]> => {
const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", {
user,
body: {
source_lng: "en",
target_lng: ["de"],
collection_slug: "docs",
collection_id: [id],
strategy: "overwrite",

publish_on_translation: false,
},
});
expect(res.status).toBe(200);
return (res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? [];
};

const seed = async (title: string, tagline: string) => {
Expand Down Expand Up @@ -129,4 +135,17 @@ describe("a queued translation is decided by whoever asked for it", () => {
"de:Legacy"
);
});

it("will not run on demand a row that recorded nobody — its write would skip every field rule", async () => {
const id = await seed("Orphan", "Orphan line");
const [assignment] = await enqueueAs(id, null);
expect(assignment, "nothing was queued, so the run route was never reached").toBeDefined();

const res = await callEndpoint(ctx.payload, "post", "/translate/run/:id", {
user: editor,
routeParams: { id: String(assignment?.handle) },
});

expect(res.status).toBe(403);
});
});

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,32 @@ import { callEndpoint } from "./callEndpoint";
// localized leaves — the recommended regime: block rows are SHARED across locales, paired by `id`
// (not position) and re-emitted in SOURCE order. These specs pin that contract through the real
// pipeline: same-type blocks each get their OWN translation, reordering the source re-mirrors order
// without cross-contaminating leaves or corrupting an already-translated locale, and skip_existing
// respects per-block edits — matched by id even when only some blocks are filled ("partially differ").
// without cross-contaminating leaves or corrupting an already-translated locale, and a filter
// naming one block's leaf leaves its siblings' target text where it stands — paired by id.

const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s);

type Block = { id?: string; blockType: string; heading?: string; caption?: string };

const filtersFrom = (rules?: string[], addresses?: string[]) => [
...(rules ?? []).map((name) => ({ kind: "rule", name })),
...(addresses ? [{ kind: "leaves", addresses }] : []),
];

const enqueue = (
ctx: TestPayload,
id: string,
target: string,
strategy: "overwrite" | "skip_existing" = "overwrite"
rules?: string[],
addresses?: string[]
) =>
callEndpoint(ctx.payload, "post", "/translate/enqueue", {
body: {
source_lng: "en",
target_lng: target,
collection_slug: "docs",
collection_id: [id],
strategy,
filters: filtersFrom(rules, addresses),
publish_on_translation: true,
},
});
Expand Down Expand Up @@ -136,7 +142,7 @@ describe("blocks — id-based pairing, ordering, partial differences", () => {
]);
});

it("skip_existing fills empty block leaves but keeps a manually-edited one (matched by id)", async () => {
it("pairs blocks by id when a run takes every leaf, keeping their order", async () => {
const created = await ctx.payload.create({
collection: "docs",
locale: "en",
Expand Down Expand Up @@ -167,10 +173,53 @@ describe("blocks — id-based pairing, ordering, partial differences", () => {
},
});

await enqueue(ctx, id, "de", "skip_existing");
await enqueue(ctx, id, "de");

const de = await sectionsOf(ctx, id, "de");
expect(de.map((b) => b.heading)).toEqual([
tr("de", "Src A"),
tr("de", "Src B"),
tr("de", "Src C"),
]);
});

it("sends one block's leaf and leaves its siblings' target text alone", async () => {
const created = await ctx.payload.create({
collection: "docs",
locale: "en",
data: {
_status: "published",
title: "Selected block",
sections: [
{ blockType: "hero", heading: "Src A" },
{ blockType: "hero", heading: "Src B" },
],
},
});
const id = String(created.id);
const en = await sectionsOf(ctx, id, "en");

await enqueue(ctx, id, "de");
const first = await sectionsOf(ctx, id, "de");
expect(first.map((b) => b.heading)).toEqual([tr("de", "Src A"), tr("de", "Src B")]);

await ctx.payload.update({
collection: "docs",
id,
locale: "en",
data: {
_status: "published",
sections: [
{ id: en[0].id, blockType: "hero", heading: "Src A, edited" },
{ id: en[1].id, blockType: "hero", heading: "Src B, edited" },
],
},
});

await enqueue(ctx, id, "de", undefined, [`sections.${en[1].id}:hero.heading`]);

const de = await sectionsOf(ctx, id, "de");
// Empty siblings filled from source; the manually-edited middle block kept — paired by id.
expect(de.map((b) => b.heading)).toEqual([tr("de", "Src A"), "MANUAL B", tr("de", "Src C")]);
expect(de[0]?.heading, "its address was not named").toBe(tr("de", "Src A"));
expect(de[1]?.heading, "named by its own id, not its position").toBe(tr("de", "Src B, edited"));
});
});
2 changes: 1 addition & 1 deletion apps/dev/src/integration/translator/bootTestPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export type TestPayload = {
* empty, not as the default locale's text. Localization-level, so it applies to the whole boot.
*/
export async function bootTestPayload(opts?: {
autoTranslate?: { targets: string[]; strategy?: "overwrite" | "skip_existing" };
autoTranslate?: { targets: string[] };
collections?: CollectionConfig[];
fallback?: boolean;
exclusiveQueue?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe("cancelling a run that has already given up", () => {
target_lng: ["de", "fr"],
collection_slug: "docs",
collection_id: [String(made.id)],
strategy: "overwrite",

publish_on_translation: false,
},
});
Expand Down Expand Up @@ -83,7 +83,7 @@ describe("cancelling a run that has already given up", () => {
target_lng: ["de", "fr"],
collection_slug: "docs",
collection_id: [String(made.id)],
strategy: "overwrite",

publish_on_translation: false,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("cancelling announces what it stopped, to a host that registered nothin
target_lng: ["de", "fr", "es"],
collection_slug: "docs",
collection_id: [String(made.id)],
strategy: "overwrite",

publish_on_translation: false,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe.skipIf(!SQLITE)("cancelling a run that is already translating", () => {
target_lng: ["de", "fr"],
collection_slug: "docs",
collection_id: [String(made.id)],
strategy: "overwrite",

publish_on_translation: false,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("the handle the enqueue answer named is the handle onCompleted carries"
target_lng: ["de", "fr"],
collection_slug: "docs",
collection_id: [String(made.id)],
strategy: "overwrite",

publish_on_translation: false,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const enqueue = (ctx: TestPayload, id: string, target: string, publish = true) =
target_lng: target,
collection_slug: "docs",
collection_id: [id],
strategy: "overwrite",

publish_on_translation: publish,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe("document translation (manual enqueue, en -> de/fr)", () => {
target_lng: target,
collection_slug: "docs",
collection_id: [id],
strategy: "overwrite",

publish_on_translation: true,
},
});
Expand Down
24 changes: 14 additions & 10 deletions apps/dev/src/integration/translator/draft-safe-writes.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const PUBLISHED_NOTE = "PUBLISHED NOTE";
const PUBLISHED_PRICE = 200;
const DRAFT_PRICE = 999;
const FOREIGN_DRAFT = "FR SECRET DRAFT";
const CLEARED_TO_GIVE_THE_RERUN_WORK = "";
const CLEARED_BY_A_PERSON = "";

type Slug = "docs" | "plain" | "auto";
type Locale = "en" | "de" | "fr";
Expand Down Expand Up @@ -58,18 +58,20 @@ const readAt = (draft: boolean) => (collection: Slug, id: string, locale: Locale
const live = readAt(false);
const asDraft = readAt(true);

const ruleFilters = (rules?: string[]) => rules?.map((name) => ({ kind: "rule", name }));

async function translate(
collection: Slug,
id: string,
opts: { publish: boolean; strategy?: "overwrite" | "skip_existing" }
opts: { publish: boolean; rules?: string[] }
): Promise<void> {
const res = await callEndpoint(payload, "post", "/translate/enqueue", {
body: {
source_lng: "en",
target_lng: "de",
collection_slug: collection,
collection_id: [id],
strategy: opts.strategy ?? "overwrite",
filters: ruleFilters(opts.rules),
publish_on_translation: opts.publish,
},
});
Expand Down Expand Up @@ -159,7 +161,7 @@ describe("draft-safe and per-locale-safe writes (#102)", () => {
expect((await live("auto", id, "de")).title).toBe(TRANSLATED);
});

it("skip_existing preserves a human's correction to a draft translation", async () => {
it("leaves a draft translation alone when the source behind it did not move", async () => {
const id = await create("docs", "published");
await translate("docs", id, { publish: false });

Expand All @@ -168,14 +170,16 @@ describe("draft-safe and per-locale-safe writes (#102)", () => {
id,
locale: "de",
draft: true,
data: { title: "HUMAN FIX", subtitle: CLEARED_TO_GIVE_THE_RERUN_WORK },
data: { title: "HUMAN FIX", subtitle: CLEARED_BY_A_PERSON },
});

await translate("docs", id, { publish: false, strategy: "skip_existing" });
await translate("docs", id, { publish: false, rules: ["stale"] });

const de = await asDraft("docs", id, "de");
expect(de.title).toBe("HUMAN FIX");
expect(de.subtitle).toBe(tr("de", SUBTITLE));
expect(de.title, "the correction stands: its source did not move").toBe("HUMAN FIX");
expect(de.subtitle, "a cleared leaf is not refilled: its source did not move").toBe(
CLEARED_BY_A_PERSON
);
});

it("publish mode takes the current draft live, including edits the translation did not touch", async () => {
Expand Down Expand Up @@ -215,11 +219,11 @@ describe("draft-safe and per-locale-safe writes (#102)", () => {
expect((await live("docs", id, "en")).nonLocalizedNote).toBe(PUBLISHED_NOTE);
});

it("publish mode with skip_existing actually publishes", async () => {
it("publish mode publishes a run narrowed to what moved", async () => {
const id = await create("docs", "published");
await translate("docs", id, { publish: false });

await translate("docs", id, { publish: true, strategy: "skip_existing" });
await translate("docs", id, { publish: true, rules: ["stale"] });

const de = await live("docs", id, "de");
expect(de.title).toBe(TRANSLATED);
Expand Down
Loading
Loading