diff --git a/apps/dev/src/integration/translator/bootTestPayload.ts b/apps/dev/src/integration/translator/bootTestPayload.ts index 50c478e55..e1bf7b08b 100644 --- a/apps/dev/src/integration/translator/bootTestPayload.ts +++ b/apps/dev/src/integration/translator/bootTestPayload.ts @@ -14,6 +14,7 @@ import { } from "@focus-reactive/payload-plugin-translator"; import type { TaskRunnerProvider, + TranslationLifecycleCallbacks, TranslationProvider, } from "@focus-reactive/payload-plugin-translator"; import { buildConfig } from "payload"; @@ -86,6 +87,7 @@ export async function bootTestPayload(opts?: { failFor?: string[]; onTranslate?: (targetLng: string) => Promise | void; runner?: TaskRunnerProvider; + lifecycle?: TranslationLifecycleCallbacks; /** Turn on container-granular rich-text translation, and declare the provider able to keep marks. */ inlineMarks?: boolean; /** @@ -161,6 +163,7 @@ export async function bootTestPayload(opts?: { runner: opts?.runner ?? createSyncRunner(), levels: opts?.fieldSurface ? [documentLevel(), fieldLevel()] : [documentLevel()], provenance: true, + ...(opts?.lifecycle ? { lifecycle: opts.lifecycle } : {}), ...(opts?.inlineMarks ? { experimental: { inlineMarks: true } } : {}), }), ], diff --git a/apps/dev/src/integration/translator/callEndpoint.ts b/apps/dev/src/integration/translator/callEndpoint.ts index 39544841e..76a1cf884 100644 --- a/apps/dev/src/integration/translator/callEndpoint.ts +++ b/apps/dev/src/integration/translator/callEndpoint.ts @@ -14,7 +14,7 @@ import type { Payload, PayloadRequest } from "payload"; */ export async function callEndpoint( payload: Payload, - method: "get" | "post", + method: "get" | "post" | "delete", path: string, opts?: { routeParams?: Record; diff --git a/apps/dev/src/integration/translator/cancel-after-giving-up.int.test.ts b/apps/dev/src/integration/translator/cancel-after-giving-up.int.test.ts new file mode 100644 index 000000000..8dba22a4b --- /dev/null +++ b/apps/dev/src/integration/translator/cancel-after-giving-up.int.test.ts @@ -0,0 +1,104 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +type Seen = { targetLng: string }; +type Assignment = { target_lng: string; handle: string }; + +const ONE_ATTEMPT_SO_THE_RUN_GIVES_UP_AT_ONCE = 1; +/** Enough passes that the run is finished one way or another before anything is read. */ +const DRAIN = 8; + +const onFailed = vi.fn<(task: Seen, error: unknown) => void>(); +const onCancelled = vi.fn<(task: Seen) => void>(); + +let ctx: TestPayload; + +describe("cancelling a run that has already given up", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ + autoRun: false, + retries: { + attempts: ONE_ATTEMPT_SO_THE_RUN_GIVES_UP_AT_ONCE, + backoff: { type: "fixed", delay: 0 }, + }, + }), + lifecycle: { onFailed, onCancelled }, + onTranslate: () => { + throw new Error("provider down"); + }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("announces nothing: every locale was settled when the run gave up", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Spent", note: "A note" }, + }); + const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de", "fr"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + const handle = ((res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? + [])[0]?.handle; + for (let pass = 0; pass < DRAIN; pass++) { + await ctx.payload.jobs.run({ queue: "translations" }); + } + expect( + onFailed.mock.calls.map(([task]) => task.targetLng).sort(), + "the run must be spent before the cancellation this check is about" + ).toEqual(["de", "fr"]); + + await callEndpoint(ctx.payload, "delete", "/translate/cancel", { body: { ids: [handle] } }); + + expect( + onCancelled.mock.calls.map(([task]) => task.targetLng), + "a locale already told it failed is settled; telling it again is a second ending" + ).toEqual([]); + }); + + it("announces nothing when the whole collection is cancelled either", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Spent too", note: "A note" }, + }); + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de", "fr"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + for (let pass = 0; pass < DRAIN; pass++) { + await ctx.payload.jobs.run({ queue: "translations" }); + } + onCancelled.mockClear(); + + await callEndpoint(ctx.payload, "delete", "/translate/cancel-by-collection/:collection_slug", { + routeParams: { collection_slug: "docs" }, + }); + + expect( + onCancelled.mock.calls.map(([task]) => task.targetLng), + "a spent run is neither completed nor running, so this sweep reaches it" + ).toEqual([]); + }); +}); diff --git a/apps/dev/src/integration/translator/cancel-announcement.int.test.ts b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts new file mode 100644 index 000000000..03fc1f45b --- /dev/null +++ b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts @@ -0,0 +1,80 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +type Seen = { targetLng: string; handle?: string }; +type Assignment = { target_lng: string; handle: string }; + +const ATTEMPTS_SO_THE_RUN_STILL_HAS_A_TRY_LEFT = 3; + +let ctx: TestPayload; + +const runStillOnRecordWhenAnnounced: boolean[] = []; + +const onCancelled = vi.fn(async (task: Seen) => { + const row = await ctx.payload.find({ + collection: "payload-jobs" as "docs", + where: { id: { equals: task.handle } } as never, + }); + runStillOnRecordWhenAnnounced.push(row.docs.length === 1); +}); + +describe("cancelling announces what it stopped, to a host that registered nothing else", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ + autoRun: false, + retries: { + attempts: ATTEMPTS_SO_THE_RUN_STILL_HAS_A_TRY_LEFT, + backoff: { type: "fixed", delay: 0 }, + }, + }), + lifecycle: { onCancelled }, + onTranslate: (targetLng) => { + if (targetLng === "fr") throw new Error("provider down"); + }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("names every locale the run still owed, and no locale it had delivered", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Stopped midway", note: "A note" }, + }); + const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de", "fr", "es"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + const handle = ((res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? + [])[0]?.handle; + await ctx.payload.jobs.run({ queue: "translations" }); + + await callEndpoint(ctx.payload, "delete", "/translate/cancel", { body: { ids: [handle] } }); + + expect(onCancelled.mock.calls.map(([task]) => task.targetLng).sort()).toEqual(["es", "fr"]); + expect( + onCancelled.mock.calls.every(([task]) => task.handle === handle), + "each locale is announced against the run that was cancelled" + ).toBe(true); + }); + + it("announces while the run is still on record", async () => { + expect( + runStillOnRecordWhenAnnounced, + "a host told after the deletion could no longer look the run up" + ).toEqual([true, true]); + }); +}); diff --git a/apps/dev/src/integration/translator/cancel-while-running.int.test.ts b/apps/dev/src/integration/translator/cancel-while-running.int.test.ts new file mode 100644 index 000000000..fa5d4abe0 --- /dev/null +++ b/apps/dev/src/integration/translator/cancel-while-running.int.test.ts @@ -0,0 +1,73 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +type Assignment = { target_lng: string; handle: string }; + +// SQLite only. Cancelling from inside the running job deletes a row that job's own transaction +// holds, which on a real transactional adapter blocks until the suite's teardown times out. What is +// under test is which callbacks the plugin fires, and that is the same on every adapter. +const SQLITE = (process.env.DB_ADAPTER ?? "sqlite") === "sqlite"; + +let ctx: TestPayload; +let handle: string | undefined; +const announced: string[] = []; + +const say = (what: string) => (task: { targetLng: string }) => { + announced.push(`${what}:${task.targetLng}`); +}; + +describe.skipIf(!SQLITE)("cancelling a run that is already translating", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ autoRun: false, retries: { attempts: 0 } }), + lifecycle: { + onCompleted: say("completed"), + onFailed: say("failed"), + onCancelled: say("cancelled"), + }, + onTranslate: async (targetLng) => { + if (targetLng === "de" && handle) { + await callEndpoint(ctx.payload, "delete", "/translate/cancel", { + body: { ids: [handle] }, + }); + } + }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("does not also report the locales as failed", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Stopped mid-flight", note: "A note" }, + }); + const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de", "fr"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + handle = ((res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? [])[0] + ?.handle; + + // Payload's own update throws when the row it is writing was deleted under it. + await ctx.payload.jobs.run({ queue: "translations" }).catch(() => undefined); + + expect(announced, "the run was cancelled, so the host is told that").toContain("cancelled:de"); + expect( + announced.filter((a) => a.startsWith("failed:")), + `nothing translated failed — a machinery error is not a locale giving up (saw: ${announced.join(", ")})` + ).toEqual([]); + }); +}); diff --git a/apps/dev/src/integration/translator/completed-handle.int.test.ts b/apps/dev/src/integration/translator/completed-handle.int.test.ts new file mode 100644 index 000000000..7d0f253be --- /dev/null +++ b/apps/dev/src/integration/translator/completed-handle.int.test.ts @@ -0,0 +1,55 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +type Seen = { targetLng: string; handle?: string }; +type Assignment = { target_lng: string; handle: string }; + +const onCompleted = vi.fn<(task: Seen) => void>(); + +let ctx: TestPayload; + +describe("the handle the enqueue answer named is the handle onCompleted carries", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ autoRun: false }), + lifecycle: { onCompleted }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("matches for every locale of the request", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Two locales", note: "A note" }, + }); + const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de", "fr"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + const promised = (res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? []; + + await ctx.payload.jobs.run({ queue: "translations" }); + + const delivered = onCompleted.mock.calls.map(([task]) => task); + expect(delivered.map((t) => t.targetLng).sort()).toEqual(["de", "fr"]); + for (const task of delivered) { + expect( + task.handle, + `the caller was told ${task.targetLng} would run as a named job; it has to be that job` + ).toBe(promised.find((j) => j.target_lng === task.targetLng)?.handle); + } + }); +}); diff --git a/apps/dev/src/integration/translator/enqueue-handles.int.test.ts b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts new file mode 100644 index 000000000..dba0f82d6 --- /dev/null +++ b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts @@ -0,0 +1,103 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +let ctx: TestPayload; + +type Assignment = { + collection_slug: string; + collection_id: string; + target_lng: string; + handle: string; +}; + +const create = async (title: string): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title, note: "A note" }, + }); + return String(made.id); +}; + +const enqueue = async (ids: string[], locales: string[]): Promise => { + const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: locales, + collection_slug: "docs", + collection_id: ids, + strategy: "overwrite", + publish_on_translation: false, + }, + }); + return (res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? []; +}; + +describe("the enqueue answer names the run that will translate each locale", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("answers once per requested locale, naming a run that really exists", async () => { + const id = await create("One document"); + + const assignments = await enqueue([id], ["de", "fr"]); + + expect(assignments.map((j) => j.target_lng).sort()).toEqual(["de", "fr"]); + const rows = await ctx.payload.find({ + collection: "payload-jobs" as "docs", + pagination: false, + where: { id: { in: assignments.map((j) => j.handle) } } as never, + }); + expect(rows.docs, "every handle answered names a row that is actually there").toHaveLength( + new Set(assignments.map((j) => j.handle)).size + ); + }); + + it("gives two locales of one document the same run", async () => { + const id = await create("Shared run"); + + const assignments = await enqueue([id], ["de", "fr"]); + + expect( + new Set(assignments.map((j) => j.handle)), + "one run covers a document's locale list" + ).toHaveLength(1); + }); + + it("keeps two documents apart", async () => { + const first = await create("First"); + const second = await create("Second"); + + const assignments = await enqueue([first, second], ["de", "fr"]); + + expect(assignments, "two documents, two locales each").toHaveLength(4); + expect(new Set(assignments.map((j) => j.handle))).toHaveLength(2); + expect( + new Set(assignments.filter((j) => j.collection_id === first).map((j) => j.handle)) + ).toHaveLength(1); + }); + + it("still names the run for a locale a live run already covers", async () => { + const id = await create("Asked twice"); + + const first = await enqueue([id], ["de"]); + const second = await enqueue([id], ["de", "fr"]); + + expect( + second.map((j) => j.target_lng).sort(), + "de needed no write, but it is being translated — leaving it out would report nothing for it" + ).toEqual(["de", "fr"]); + expect( + second.find((j) => j.target_lng === "de")?.handle, + "and it is the run that already had it" + ).toBe(first[0]?.handle); + }); +}); diff --git a/apps/dev/src/integration/translator/final-failure.int.test.ts b/apps/dev/src/integration/translator/final-failure.int.test.ts new file mode 100644 index 000000000..8f65886ec --- /dev/null +++ b/apps/dev/src/integration/translator/final-failure.int.test.ts @@ -0,0 +1,112 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +const ATTEMPTS = 2; +const PASSES_UNTIL_THE_RUN_GIVES_UP = ATTEMPTS + 1; + +type Seen = { targetLng: string; handle?: string }; + +const onCompleted = vi.fn<(task: Seen) => void>(); +const onFailed = vi.fn<(task: Seen) => void>(); + +const localesOf = (fn: typeof onFailed) => fn.mock.calls.map(([t]) => t.targetLng).sort(); + +let ctx: TestPayload; +let providerIsDown = true; + +describe("onFailed reports a final outcome, not an attempt", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ + autoRun: false, + // The shipped default backs off exponentially from five seconds; this spec would then be + // minutes of waiting. + retries: { attempts: ATTEMPTS, backoff: { type: "fixed", delay: 0 } }, + }), + lifecycle: { onCompleted, onFailed }, + onTranslate: (targetLng) => { + if (targetLng === "de" && providerIsDown) throw new Error("provider down"); + }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + const run = async (title: string, locales: string[]) => { + onCompleted.mockClear(); + onFailed.mockClear(); + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title, note: "A note" }, + }); + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: locales, + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + for (let pass = 0; pass < PASSES_UNTIL_THE_RUN_GIVES_UP; pass++) { + await ctx.payload.jobs.run({ queue: "translations" }); + } + }; + + it("says nothing while the run is still retrying, and reports success once it works", async () => { + providerIsDown = true; + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Flaky", note: "A note" }, + }); + onCompleted.mockClear(); + onFailed.mockClear(); + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + await ctx.payload.jobs.run({ queue: "translations" }); + providerIsDown = false; + await ctx.payload.jobs.run({ queue: "translations" }); + + expect( + onFailed, + "a locale that failed once and then worked never failed, as far as the host is concerned" + ).not.toHaveBeenCalled(); + expect(localesOf(onCompleted)).toEqual(["de"]); + }); + + it("reports every locale the run never got to, not just the one that broke", async () => { + providerIsDown = true; + + await run("Abandoned", ["de", "fr"]); + + expect( + localesOf(onFailed), + "fr never started, because the run stopped at de — and it never will" + ).toEqual(["de", "fr"]); + }); + + it("does not report a locale the run had already translated", async () => { + providerIsDown = true; + + await run("Partly done", ["fr", "de"]); + + expect(localesOf(onCompleted), "fr went first and worked").toEqual(["fr"]); + expect(localesOf(onFailed), "only de is owed").toEqual(["de"]); + }); +}); diff --git a/apps/dev/src/integration/translator/gave-up-mid-run.int.test.ts b/apps/dev/src/integration/translator/gave-up-mid-run.int.test.ts new file mode 100644 index 000000000..c2c3d11e2 --- /dev/null +++ b/apps/dev/src/integration/translator/gave-up-mid-run.int.test.ts @@ -0,0 +1,74 @@ +import { + createPayloadJobsRunner, + TranslationProviderError, +} from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +const ATTEMPTS = 2; +/** Enough passes that the run is finished with one way or another before anything is read. */ +const DRAIN = 8; + +type Seen = { targetLng: string }; + +const onCompleted = vi.fn<(task: Seen) => void>(); +const onFailed = vi.fn<(task: Seen, error: unknown) => void>(); + +let ctx: TestPayload; +let frHasFailedOnce = false; + +describe("a run that spends its attempts across more than one locale", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ + autoRun: false, + retries: { attempts: ATTEMPTS, backoff: { type: "fixed", delay: 0 } }, + }), + lifecycle: { onCompleted, onFailed }, + onTranslate: (targetLng) => { + if (targetLng === "fr" && !frHasFailedOnce) { + frHasFailedOnce = true; + throw new Error("provider down"); + } + if (targetLng === "de") throw new Error("provider down"); + }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("reports the locale it abandoned, not only the one that spent its own budget", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Spread thin", note: "A note" }, + }); + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["fr", "de"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + for (let pass = 0; pass < DRAIN; pass++) { + await ctx.payload.jobs.run({ queue: "translations" }); + } + + expect(onCompleted.mock.calls.map(([t]) => t.targetLng)).toEqual(["fr"]); + expect( + onFailed.mock.calls.map(([t]) => t.targetLng), + "the run is over and de was never translated — silence here leaves it in flight forever" + ).toEqual(["de"]); + expect( + onFailed.mock.calls[0]?.[1], + "a host matches on the plugin's exported error classes; Payload's message-only copy is not one" + ).toBeInstanceOf(TranslationProviderError); + }); +}); diff --git a/apps/dev/src/integration/translator/queued-before-any-ending-on-jobs.int.test.ts b/apps/dev/src/integration/translator/queued-before-any-ending-on-jobs.int.test.ts new file mode 100644 index 000000000..221751173 --- /dev/null +++ b/apps/dev/src/integration/translator/queued-before-any-ending-on-jobs.int.test.ts @@ -0,0 +1,84 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +type Heard = { event: string; targetLng: string }; + +const heard: Heard[] = []; +const hear = + (event: string) => + (task: { targetLng: string }): void => { + heard.push({ event, targetLng: task.targetLng }); + }; + +/** Enough passes that the run is finished one way or another before anything is read. */ +const DRAIN = 8; + +let ctx: TestPayload; +let heardAtEnqueue: Heard[] = []; + +describe("a host hears onQueued before any ending, with the runner that queues onto Payload", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ + autoRun: false, + retries: { attempts: 1, backoff: { type: "fixed", delay: 0 } }, + }), + lifecycle: { + onQueued: hear("queued"), + onCompleted: hear("completed"), + onFailed: hear("failed"), + }, + onTranslate: (targetLng) => { + if (targetLng === "fr") throw new Error("provider down"); + }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("fires once per requested locale, each before that locale's ending", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Heard in order", note: "A note" }, + }); + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de", "fr"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + heardAtEnqueue = [...heard]; + for (let pass = 0; pass < DRAIN; pass++) { + await ctx.payload.jobs.run({ queue: "translations" }); + } + + for (const targetLng of ["de", "fr"]) { + const forLocale = heard.filter((entry) => entry.targetLng === targetLng); + expect( + forLocale.filter((entry) => entry.event === "queued"), + `${targetLng} is announced queued exactly once` + ).toHaveLength(1); + expect( + forLocale[0]?.event, + `${targetLng} ended before the host was told it had started` + ).toBe("queued"); + } + }); + + it("has told the host about every locale before the enqueue answers", () => { + expect( + heardAtEnqueue.map((entry) => `${entry.event}:${entry.targetLng}`), + "a host that only learns of the work once it is over cannot show it as pending" + ).toEqual(["queued:de", "queued:fr"]); + }); +}); diff --git a/apps/dev/src/integration/translator/queued-before-any-ending.int.test.ts b/apps/dev/src/integration/translator/queued-before-any-ending.int.test.ts new file mode 100644 index 000000000..8cbe21ca3 --- /dev/null +++ b/apps/dev/src/integration/translator/queued-before-any-ending.int.test.ts @@ -0,0 +1,71 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +type Heard = { event: string; targetLng: string }; + +const heard: Heard[] = []; +const hear = + (event: string) => + (task: { targetLng: string }): void => { + heard.push({ event, targetLng: task.targetLng }); + }; + +let ctx: TestPayload; + +/** The harness boots the synchronous runner, which translates inside `enqueue`. */ +describe("a host hears onQueued before any ending, with the runner that translates inline", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + lifecycle: { + onQueued: hear("queued"), + onCompleted: hear("completed"), + onFailed: hear("failed"), + }, + onTranslate: (targetLng) => { + if (targetLng === "fr") throw new Error("provider down"); + }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("fires once per requested locale, each before that locale's ending", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Heard in order", note: "A note" }, + }); + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de", "fr"], + collection_slug: "docs", + collection_id: [String(made.id)], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + + for (const targetLng of ["de", "fr"]) { + const forLocale = heard.filter((entry) => entry.targetLng === targetLng); + expect( + forLocale.filter((entry) => entry.event === "queued"), + `${targetLng} is announced queued exactly once` + ).toHaveLength(1); + expect( + forLocale[0]?.event, + `${targetLng} ended before the host was told it had started` + ).toBe("queued"); + } + expect(heard.map((entry) => `${entry.event}:${entry.targetLng}`)).toEqual([ + "queued:de", + "completed:de", + "queued:fr", + "failed:fr", + ]); + }); +}); diff --git a/apps/dev/src/integration/translator/retranslate-after-giving-up.int.test.ts b/apps/dev/src/integration/translator/retranslate-after-giving-up.int.test.ts new file mode 100644 index 000000000..65242493c --- /dev/null +++ b/apps/dev/src/integration/translator/retranslate-after-giving-up.int.test.ts @@ -0,0 +1,70 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +const RETRIES = 1; +const PASSES_UNTIL_THE_RUN_GIVES_UP = RETRIES + 1; + +type Assignment = { target_lng: string; handle: string }; + +let ctx: TestPayload; +let providerIsDown = true; + +const enqueue = async (id: string, locales: string[]): Promise => { + const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: locales, + collection_slug: "docs", + collection_id: [id], + strategy: "overwrite", + publish_on_translation: false, + }, + }); + return (res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? []; +}; + +describe("asking again after a run has given up", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ + autoRun: false, + retries: { attempts: RETRIES, backoff: { type: "fixed", delay: 0 } }, + }), + onTranslate: (targetLng) => { + if (targetLng === "de" && providerIsDown) throw new Error("provider down"); + }, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("names a run that will really translate the locale, not the one that stopped", async () => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title: "Asked again", note: "A note" }, + }); + const id = String(made.id); + + const first = await enqueue(id, ["de"]); + for (let pass = 0; pass < PASSES_UNTIL_THE_RUN_GIVES_UP; pass++) { + await ctx.payload.jobs.run({ queue: "translations" }); + } + + providerIsDown = false; + const second = await enqueue(id, ["de"]); + await ctx.payload.jobs.run({ queue: "translations" }); + + expect( + second[0]?.handle, + "the first run is spent — Payload will never pick it up again, so naming it promises nothing" + ).not.toBe(first[0]?.handle); + const translated = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); + expect(translated.title, "and the locale is actually translated").not.toBe("Asked again"); + }); +}); diff --git a/apps/dev/src/integration/translator/task-runner-contract.int.test.ts b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts new file mode 100644 index 000000000..296d17a0f --- /dev/null +++ b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts @@ -0,0 +1,23 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, vi } from "vitest"; + +import { assertTaskRunnerContract } from "../../../../../packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants"; +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; + +let ctx: TestPayload; + +beforeAll(async () => { + ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); +}); +afterAll(async () => { + await ctx?.cleanup(); +}); + +assertTaskRunnerContract("PayloadJobsTaskRunner", (report) => + createPayloadJobsRunner({ autoRun: false }).create(ctx.payload, { + handler: vi.fn().mockResolvedValue(undefined), + collections: [], + report, + }) +); diff --git a/docs/adr/0002-publish-only-what-a-consumer-must-name.md b/docs/adr/0002-publish-only-what-a-consumer-must-name.md new file mode 100644 index 000000000..384cf27d8 --- /dev/null +++ b/docs/adr/0002-publish-only-what-a-consumer-must-name.md @@ -0,0 +1,72 @@ +# ADR-0002: A published barrel exports only what a consumer must name + +- **Status:** Accepted +- **Date:** 2026-10-08 +- **Scope:** every publishable package — the `src/index.ts` each one ships as its entry point + +## Context + +A package's `index.ts` is the only part of it that is a promise. Everything behind it can be +renamed, reshaped or deleted between releases; everything in front of it cannot, and a type +published once is a type someone can import, annotate with, and build on. + +Re-exports arrive there for reasons that feel like diligence at the time: + +- a reviewer observes that an outside implementer "cannot name" a type the contract mentions; +- a symbol is needed by a test or a fixture, and the barrel is the nearest import path; +- a type is already exported from a module barrel one level down, so lifting it looks free. + +None of these is evidence that a consumer needs the name. In the case that produced this ADR, +`EnqueueAssignment` and `Task` were added to the translator's barrel so a third-party `TaskRunner` +could annotate what it returns. A compile-only probe then showed that a complete outside runner +compiles **without naming either type**: the shapes are inferred from `TaskRunnerProvider`, the one +name that genuinely crosses the boundary. The export enabled nothing. + +What it did **not** do is make those types public — they already were. `TaskRunnerProvider` is +exported, its `create` returns a `TaskRunner`, and that interface's methods answer with +`EnqueueAssignment[]` and `Task[]`. Every one of those names is emitted into the published +declarations and reachable through `ReturnType`, `Parameters` or a direct import. A barrel decides +how conveniently a consumer can *name* a type, not whether the type is part of the contract. + +## Decision + +**A symbol belongs in a published `index.ts` only when a consumer outside the package cannot do +their job without naming it.** Before adding a re-export, answer three questions in order: + +1. **Can the consumer's code compile without the name?** Write the consumer — twenty lines in a + scratch file is enough — and type-check it. Contextual typing from an exported interface covers + object literals, callbacks and return positions. If it compiles, the export is not needed. +2. **Who is the consumer, concretely?** A test, a fixture, or another file inside this repository is + not one; it has internal import paths. "A third party might" is not one either unless the export + is what makes their code possible, which question 1 settles. +3. **Is the surface coherent without it?** Exporting a return type while the interface that returns + it stays internal is a half-measure: it publishes maintenance cost without publishing capability. + Either the whole seam is public or none of it is. + +Three consequences follow: + +- **An unexported type is not a defect.** A review finding of the form "an implementer cannot name + X" is answered by question 1, not by an export. Record the answer; do not widen the surface. +- **When a seam does become public, it goes whole.** The interface, its inputs and its outputs + together — and not before the types in it are tight enough to live with. +- **Reachability is the real test of what is public, and it is wider than the barrel.** Anything a + published interface leads to is already a compatibility obligation: reshaping it breaks consumers + whether or not its name was ever re-exported. So the question "is this type public?" is answered by + following the signatures out of `index.ts`, not by reading the export list. Keeping a loose type + out of the barrel buys tidiness, never freedom to change it. + +## Consequences + +- Fixtures and conformance files import the published name where they are *proving* the published + path works, and internal paths otherwise. A fixture that imports five internal names to exercise a + one-name public surface is testing the wrong thing. +- The per-package `@since` rule still applies to whatever does get published; this ADR decides *what* + is published, that rule decides how it is annotated. +- Removing an export that was never released costs nothing. Removing one that shipped is a breaking + change, which is the asymmetry this ADR exists to respect. + +## Status of the case that prompted it + +`EnqueueAssignment` and `Task` were removed from the translator's barrel before 0.16.0 shipped, and +`TaskRunner.conformance.types.ts` was rewritten to import one name and infer the rest — which is now +the fixture's point as well as its implementation. diff --git a/packages/payload-plugin-translator/CLAUDE.md b/packages/payload-plugin-translator/CLAUDE.md index 0ff874695..d785e8a3a 100644 --- a/packages/payload-plugin-translator/CLAUDE.md +++ b/packages/payload-plugin-translator/CLAUDE.md @@ -3,6 +3,17 @@ Package-specific conventions for `@focus-reactive/payload-plugin-translator`. Complements the root [CLAUDE.md](../../CLAUDE.md) — it does not replace it. +## What goes into `src/index.ts` + +**Only what a consumer outside the package cannot do their job without naming.** Before +adding a re-export, write the consumer in a scratch file and type-check it: contextual +typing from an already-exported interface covers object literals, callbacks and return +positions, so a type the contract merely *mentions* usually needs no export at all. A test +or fixture in this repository is not a consumer — it has internal import paths. + +See [ADR-0002](../../docs/adr/0002-publish-only-what-a-consumer-must-name.md) for the three +questions and the case that produced them. + ## Documenting feature versions Annotate every new piece of **public API** (anything re-exported from diff --git a/packages/payload-plugin-translator/docs/DEPRECATIONS.md b/packages/payload-plugin-translator/docs/DEPRECATIONS.md index e729f4abd..24bf33102 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -250,3 +250,46 @@ the single source of truth — code annotations link here by anchor instead of d - `src/core/translation-pipeline/translateContent.ts` (the single switch between the two paths) - `src/core/translation-pipeline/stages/text-expander/RichContainerExpander.ts` - `docs/plans/2026-09-08-richtext-container-granularity-design.md` (D8, D8a) + +### enqueue-void-return + +- **What:** `TaskRunner.enqueue` resolving with nothing. +- **Status:** live +- **Deprecated:** 2026-10-07 / PR pending (issues #107, #110) +- **Replacement:** resolve with one assignment per requested locale — the shape `TaskRunnerProvider` + infers for `enqueue`; an implementation returns it as an object literal and needs no import. +- **Remove in:** next major +- **Why:** the handle is what every later message about the work is keyed by — `onCompleted`, + `onFailed`, `onCancelled`, the enqueue answer and the cancel route all name it. A runner that + answers with nothing leaves its host unable to connect any of them to the request it made. +- **Why the return type widened instead of changing:** `enqueue` is implemented outside this + package. Narrowing it to `EnqueueAssignment[]` would break every third-party runner on upgrade, + and a pair of overloads cannot express the transition — an implementation must satisfy every + overload, so the old shape would stop compiling just the same. +- **What a `void` runner loses today:** nothing throws and nothing is logged. The enqueue response + reports no handles, and the lifecycle callbacks arrive without one. Both degrade silently and on + purpose: the alternative is a warning on a path the host cannot fix from where it sees it. +- **Code refs:** + - `src/server/modules/task-runner/TaskRunner.interface.ts` (the widened return) + - `src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts` (a `void` runner + still compiles; narrowing the contract fails the build) + - `src/server/features/enqueue-translation/handler.ts` (the answer, when handles exist) + +### on-failed-per-attempt + +- **What:** `lifecycle.onFailed` firing once per execution attempt. +- **Status:** removed (behaviour change, not an annotated symbol) +- **Changed:** 2026-10-07 / PR pending (issue #110) +- **Replacement:** `onFailed` fires once per target locale, and only when that locale will not be + translated. A runner that retries decides when that is and says so itself, by reporting the + `failed` event of `TaskRunnerContext.report`. +- **Why this is listed although nothing was renamed:** a host counting `onFailed` calls, or treating + one as "this translation is dead", was reading a signal that did not mean that. Silently making it + mean that is the fix, but an install that disabled retries *because* of the old behaviour should + know it can turn them back on. +- **What else moved with it:** a locale the run never reached is now reported too, with the error + that ended the run rather than one of its own — nothing ran for it to throw. +- **Code refs:** + - `src/server/features/translate-document/wireTranslateRunner.ts` (turns a reported event into a callback) + - `src/server/modules/task-runner/payload-jobs-runner/owedIfGaveUp.ts` (when a run has stopped for good) + - `src/server/modules/lifecycle/types.ts` (`TranslationLifecycleCallbacks`) diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-06-lifecycle-run-context.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-06-lifecycle-run-context.task.md new file mode 100644 index 000000000..eaa27701d --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-06-lifecycle-run-context.task.md @@ -0,0 +1,363 @@ +# Run context through the translation lifecycle — #107, #110, #106, #103 + +Branch `feat/translator-lifecycle-run-context`, from `main` at `95f8f8d6` (0.15.0 released). +Target **0.16.0** — everything here is additive or a widening. + +## Task restatement + +Four issues from one consumer building an editorial process on top of the plugin. They are one +problem seen from four sides: **a host cannot connect what it asked for to what the plugin is +doing.** + +The sharpened version, after reading the code: cancellation already works and the ids are already +obtainable, so #107 on its own is a convenience. **The real defect is that lifecycle callbacks carry +nothing that identifies the run** — so whatever the host stores, the event never mentions it. That is +#110, and the other three are around it. + +## Premises that expired since 0.10.1 + +Checked against `main`, not assumed: + +| Issue claims | Actually | +|---|---| +| #106: the plugin supersedes — cancels and deletes a prior job on enqueue | **Gone.** `PayloadJobsTaskRunner.test.ts:234` is a regression test: "never cancels or deletes a job when enqueuing". Only explicit cancellation remains, so a `reason` argument would have one possible value and is not worth having. | +| #107: respond with `jobs: { fr, de, it }` | **Does not fit.** One run covers a list of locales (`payload-jobs-runner/types.ts:81`) and one request spans several documents (`enqueue-translation/model.ts:13`). A locale-keyed map repeats one value and collapses across documents. | +| #110: one open run per (document, locale) | A constraint the **host** imposes on itself to make its join unambiguous. Removing the need for it is the point. | + +## Human choices + +**D1 — what "the run" is: the pair (handle, target locale).** Chosen at the gate 2026-10-06. +Rejected: an opaque `context` the host supplies and the plugin stores — the only option that also +answers "why did this run happen", but the plugin would take permanent custody of host-shaped data of +unknown size. Rejected: a plugin-minted run id — a second identifier beside one that already exists, +answering no extra question. + +**D2 — the handle is opaque, and only the runner that minted it may interpret it.** The plugin +carries it from `enqueue` into the response and into the callbacks, and hands it back verbatim to +`cancel` and the by-handle read. It never parses it, constructs one, or keys its own state by it. +This is why the design survives a remote runner, and why the granularity does not matter: the +built-in runner mints one handle per document covering a list of locales; another could mint one per +locale. `JobIdSchema` already demands only "non-empty string or finite number", so nothing in the +plugin assumes a shape today — this records the rule rather than introducing it. + +**D3 — the enqueue response is a flat list, one entry per requested locale:** +`{ collection_id, target_lng, handle }[]`. Rejected: a nested map mirroring the request — reads well +for one document, badly for `select_all`, and the flat list is what a host writing one row per +(document, locale) consumes. It also accepts any handle granularity, per D2. + +**D4 — `TaskRunner.enqueue` widens its return; it does not change it.** `TaskRunnerProvider` is +exported from `src/index.ts`, so implementations exist outside this package. The old `void` answer +keeps satisfying the contract and is deprecated for the next major, with a ledger entry. An overload +pair is **not** usable: an implementation must satisfy every overload, which breaks every third-party +runner. + +**D5 — `onFailed` fires only on FINAL failure. There is no `attempt` field.** Retries are the +runner's internal business; while they are still happening the translation has not failed. Today the +callback fires per attempt (`wireTranslateRunner.ts:76`, inside the `catch`), which is why the +consumer runs with retries disabled — "failed" currently does not mean failed. + +*Rejected: an `attempt` counter*, which is what #110 asks for by name. It lets a host tell the events +apart but still cannot say whether another try is coming, so it does not let them turn retries back +on — it answers the request, not the need. + +**D5a — finality is observed, not computed.** The jobs runner fires `onFailed` from a hook on the +jobs collection, when Payload marks a row as having given up — not from the `catch` in +`wireTranslateRunner.ts`. + +*Rejected: deriving it in the `catch`* by comparing the limit the plugin configured against the count +Payload keeps on the row. It is workable, and both numbers are ours, but it has to reproduce +Payload's comparison exactly — Payload gives up when *previous* attempts reach the limit and reads +that count before the run, so `attempts: 3` means **four** executions, and the natural reading fires +a run early. + +**What decided it is not the arithmetic — it is coverage.** The workflow translates locales in +sequence and stops at the first throw (`PayloadJobsRunnerProvider.ts`, the `for` loop around +`await runLocale(...)`). So when a run gives up, its locales are in three states: translated, failed +terminally, and **never started**. The `catch` can only ever speak for the second — the handler for +the rest was never invoked, so there is nothing to catch. Reading the row sees all three, and lets +the host be told about the abandoned locales too, which is what it actually needs in order to stop +showing them as in flight. + +Mechanism: `jobsCollectionOverrides` (`payload/dist/queues/config/types/index.d.ts:159`) lets the +plugin add an `afterChange` hook to the jobs collection. Two things it must not get wrong: + +1. **Compose, do not replace.** `jobsCollectionOverrides` is one function per application. If the + host already set one, call it and extend its result — otherwise the plugin silently drops the + host's own configuration. +2. **Fire on the transition only** — when the "gave up" flag goes from absent to present. Any later + write to the row would otherwise re-announce a failure that was already reported. + +The sync runner is unaffected: it never retries, so every failure it sees is already final and it +keeps reporting from its own path. One contract, two routes to it. + +Cost accepted: a host loses visibility of transient failures. They remain in Payload's job log, and a +separate callback for them is not worth inventing until someone asks. This is an observable change to +a documented contract (`lifecycle/types.ts` currently says the callbacks "fire per execution +attempt"), so it needs a ledger entry and a line in the release notes. + +**D6 — `onCancelled` learns the document and locale through a new OPTIONAL by-handle read on +`TaskRunner`.** A runner that omits it keeps working and simply never fires that callback. +Rejected: the cancel route resolving a collection slug first — it holds only handles, so it would +either demand the slug from the caller or scan every configured collection, and it pushes runner +knowledge into an HTTP handler. Rejected: `onCancelled` carrying only handles — cheapest, but it +makes the host resolve them itself, which is the exact complaint in #103. + +**D7 — `onQueued` carries no handle.** The decorator fires it *before* delegating, because the sync +runner translates inline during `enqueue` and would otherwise deliver "completed" before "queued". +The handle does not exist until `enqueue` returns. Consequence, accepted and to be documented: for an +HTTP-initiated run the enqueue response is the handle channel; for **auto-translate**, which a save +hook triggers with no response to return, there is no queue-time correlation at all — a host must +create its row on the first `onCompleted` / `onFailed`. + +**D8 — two identical concurrent requests for one pair coalesce into one run**, by design: a repeat +request joins the live run rather than starting a second. Both of the host's intents therefore see +one handle and one event. Semantically right — there was one translation — but it means #110 is +narrowed rather than erased, and the README should say so. + +**D9 — `onCancelled` does not fire for a locale the run already translated.** It was not cancelled. + +**D10 — the sync runner does not implement the by-handle read.** Its `cancel` is a documented no-op +(`SyncTaskRunner.ts:72`) because the work is already done, so announcing a cancellation there would +report something that never happened. + +## Acceptance criteria + +| # | Criterion | How it is checked | Expected | +|---|---|---|---| +| 1 | `POST /translate/enqueue` answers with one entry per requested locale, each naming the handle that will run it | integration, real database | entries match the request | +| 2 | Two locales of one document name the same handle | integration | one handle, two entries | +| 3 | A request across two documents keeps them distinguishable | integration | four entries, two handles | +| 4 | A locale already covered by a live run is still named in the response | unit on `planEnqueue` + integration | the live run's handle, not an empty slot | +| 5 | `onCompleted` carries the handle the enqueue response named | integration, all adapters | equal | +| 6 | A locale that fails twice then succeeds fires `onFailed` **zero** times | integration, failing provider | `onFailed` not called; `onCompleted` once | +| 7 | A locale that exhausts its retries fires `onFailed` exactly once | integration | one call | +| 7a | A locale that never ran, because an earlier locale in the same run failed terminally, is still reported as failed | integration: two locales, the first fails until the run gives up | `onFailed` for both, not just the first | +| 7b | A locale the run already translated is **not** reported as failed when the run later gives up on another locale | integration: first locale succeeds, second exhausts its retries | `onFailed` only for the second | +| 8 | `onCancelled` fires once per locale the cancelled run still owed | integration | one call per unfinished locale | +| 9 | `onCancelled` fires before the run's row is deleted | integration, reading the row inside the callback | the row resolves | +| 10 | A runner without the by-handle read still cancels, silently | unit | cancel happens, no callback, no throw | +| 11 | `GET /translate/collection/:slug` names the document and locale | unit | both present | +| 12 | A third-party runner returning `void` from `enqueue` still compiles and still queues | compile-only fixture under `check-types` | narrowing the contract fails the build | +| 13 | A host registering only `onCancelled` receives it | integration | fires | +| 14 | Every new public symbol carries `@since 0.16.0` | grep over the diff | one per symbol | +| 15 | Nothing else regresses | three checks + the integration matrix (three adapters, each with and without the exclusive queue) | at or above baseline | + +**Baselines on this branch, measured before any change:** unit **1929** in 136 files; `check-types` +clean; oxlint **55 warnings / 0 errors**. + +## Criteria pre-flight — run 2026-10-06 against the untouched tree + +All of 1-14 fail; 15 passes. Each checked at a line rather than assumed: + +| # | Why it fails today | +|---|---| +| 1-3 | `enqueue-translation/handler.ts:98` answers `{ success, queued }` — a count and nothing else | +| 4 | `planEnqueue.ts:20` — `EnqueuePlan` is `{ host, append, queue }`; a locale already covered has no slot | +| 5 | `TranslationTask` has no handle field | +| **6, 7** | `wireTranslateRunner.ts:76` fires `failed` inside the `catch` with no finality check — **per attempt**. The only criterion that asks existing behaviour to change, so the only one at real risk of already passing. It does not. | +| 8, 9, 13 | there is no `onCancelled` | +| 10 | no by-handle read on the contract (0 occurrences) | +| 11 | `get-collection-status/handler.ts:42` maps to `{ id, status }`, discarding the two fields it holds | +| 12 | `enqueue` returns `Promise`, so there is no union to narrow and the fixture cannot be written | +| 14 | no new public symbols yet | +| 15 | **passes** — 1929 unit / 136 files, `check-types` clean, oxlint 55-0 | + +**Surfaced by the pre-flight and not previously in this plan:** `wireTranslateRunner.ts:87` gates the +decorator on `lifecycle.onQueued` alone — + +```ts +if (!lifecycle.onQueued) return taskRunner; +``` + +so a host registering **only** `onCancelled` is handed an undecorated runner and hears nothing. The +gate has to widen to "any callback the decorator itself fires", which is what criterion 13 pins. + +## Known gap, surfaced not hidden + +`withQueuedNotification.ts:19` currently does `await runner.enqueue(...)` and discards the result. +Left alone, every host that registers `onQueued` would silently receive an empty handle list. Fixed +as part of this change; criterion 1 is run through a decorated runner so it cannot regress. + +## Risk + +**High.** Two externally-implemented contracts, four HTTP responses, two runner implementations, and +a retry path that only shows itself on a real database. + +### One behaviour change outside the criteria — the task id reaches the host as a string + +`normalizeJob` returned Payload's row id untouched, so on SQLite and Postgres a `Task.id` was a +number although `Task.id`, `JobStatusOutput.id` and `CollectionStatusItem.id` all declare `string` and +every client fixture already uses one. The handle made the inconsistency reachable: the same run +arrived as `"12"` through the execution path and `12` through the cancellation path, and the +integration check comparing the two is what found it. + +It is fixed at the ingress — one `handleOf` for the loosely-typed queue reply, `String(job.id)` in +`normalizeJob` — so one spelling leaves the plugin. **What this changes for an existing install:** +`GET /translate/document/:slug/:id` and `GET /translate/collection/:slug` answer with `"id": "12"` +where they answered `"id": 12`. A host that compared that field to a number is affected. The +declared type always said `string`, so nothing documented changes, but the wire does. + +### Cancelling does not stop a locale already in flight + +`TaskRunner.cancel` cancels the Payload job and deletes its row; the handler that is mid-translation +does not notice and finishes. So that locale is announced cancelled and then completed — two events +for one locale, against the rule stated beside the callbacks. Deleting the row under a running job +also makes Payload's own `updateJob` throw, so `payload.jobs.run` rejects. + +Both are older than this task — `cancel` is byte-identical to `main` — and fixing them means +changing what cancellation does, which is its own piece of work. What this task did fix is the part +it caused: a run whose row vanished under it used to be reported to `onFailed` as every locale giving +up, because the machinery error reached the same catch as a translation failure. +`cancel-while-running.int.test.ts` pins that, and the callback docblock now states the exception. +That check runs on SQLite only: cancelling from inside the running job deletes a row the job's own +transaction holds, which on Postgres blocks until the suite's teardown times out. Which callbacks +fire is plugin logic and does not vary by adapter, so the narrower run still answers the question. + +## Follow-ups — deliberately not in this task + +- **Rework `TaskRunner` into a small required core plus declared optional capabilities.** Today the + interface demands `findByCollection`, which requires the runner to index work by the *plugin's* + domain concepts — a reasonable ask of an in-process Payload runner, an unreasonable one of a remote + queue. A split into required `enqueue` / `cancel` and optional `inspect` / `rerun` would let a + runner declare what it can do and let the plugin degrade rather than break. D6's optional read is + the first instance of that shape; the rework itself is its own piece of work. +- A signal for "this failure is not final", if a host ever wants per-attempt visibility back. + +## Verification — 2026-10-07, each criterion against the check it declared + +| # | Verdict | Evidence | +|---|---|---| +| 1 | met | `enqueue-handles.int.test.ts` — "answers once per requested locale, naming a run that really exists", on every adapter | +| 2 | met | same file — "gives two locales of one document the same run" | +| 3 | met | same file — "keeps two documents apart" | +| 4 | met | `planEnqueue.test.ts` — "reports a locale the live job already owes as covered by it"; `enqueue-handles` — "still names the run for a locale a live run already covers" | +| 5 | met | `completed-handle.int.test.ts`, on every adapter | +| 6 | met | `final-failure.int.test.ts` — "says nothing while the run is still retrying, and reports success once it works" | +| 7 | met | `final-failure` — the reported set is compared whole, so a second call for one locale fails it | +| 7a | met | `final-failure` — "reports every locale the run never got to"; `gave-up-mid-run.int.test.ts` for the case where the run, not the locale, runs out of attempts | +| 7b | met | `final-failure` — "does not report a locale the run had already translated" | +| 8 | met | `cancel-announcement.int.test.ts` — "names every locale the run still owed, and no locale it had delivered" | +| 9 | met | same file — "announces while the run is still on record" | +| 10 | met | `withQueuedNotification.test.ts` — cancels, announces nothing, and is not asked to resolve | +| 11 | met | `get-collection-status/handler.test.ts` — fixtures differ by document as well as locale | +| 12 | met | `TaskRunner.conformance.types.ts` under `check-types`, written against the published barrel; narrowing `enqueue` produces 6 type errors | +| 13 | met | the `cancel-announcement` boot registers `onCancelled` and nothing else; narrowing `needsDecoration` back to `onQueued` turns it red | +| 14 | met | swept over the diff: `handle`, `onCancelled`, `EnqueueAssignment`, `findByIds`, the widened `enqueue`, `TaskHandlerInput.handle`, `reportFinalFailure`, `reportsFinalFailure` | +| 15 | met | 2015 unit / 142 files (baseline 1929 / 136) · `check-types` 0 · oxlint 55-0 (baseline 55-0) · declaration build green · integration 6 of 6 combinations | + +None recorded `not verified`: every criterion was checked the way it said it would be. + +## Review log + +### 2026-10-07 — comment audit (fresh-eyes auditor, 45 blocks) + +Density was 2.5 comment lines per 10 lines of code, all of it in docblocks: roughly 14 doc lines per +new exported symbol, with four units documented at several times their own length (`stillOwed` 35 +doc / 3 code, `alreadyCovered` 37 / 13, `isLastAttempt` 17 / 5). 10 deleted, 24 shortened, 2 turned +into code changes, 9 kept. Result: **163 lines over 1203, 1.35 per 10.** + +Two findings became code rather than prose: the workflow handler's catch body became +`owedIfGaveUp.ts`, with `taskStatus` given a typed home on `PayloadJob` instead of a structural +cast; and `final-failure.int.test.ts`'s load-bearing `<=` became `PASSES_UNTIL_THE_RUN_GIVES_UP`. +Two `{@link}` references that did not resolve (the symbol was never imported) were corrected. One +verdict could not be applied as given: `SyncTaskRunner.cancel`'s body comment was load-bearing for +`no-empty-function`, so the body became `return Promise.resolve()` instead. + +### 2026-10-07 — fresh-eyes review of the whole diff + +Six findings were real and are fixed; each is pinned by a check that was proven to go red without +the fix. + +1. **A run could be abandoned with `onFailed` never firing.** Payload gives up two ways — the + locale's own attempts against the task limit, and the whole run's executions against the workflow + limit (`getWorkflowRetryBehavior.js:8`) — and only the first was reproduced. The counters diverge + as soon as attempts are spent on more than one locale, and the run's runs out first, so a run that + had stopped was read as still trying. `owedIfGaveUp` now asks both. Pinned by + `gave-up-mid-run.int.test.ts`; reverting the second counter turns it red. +2. **The enqueue answer named a run that had already given up.** Found independently here and by the + reviewer. `pickHost` now refuses a run carrying `hasError`. Pinned by + `retranslate-after-giving-up.int.test.ts`, which was written red and watched fail. +3. **A cancelled run reported every locale as failed.** Deleting the row under a running job makes + Payload's own machinery throw, and that throw reached the same catch as a translation failure. + The report now happens only when the plugin's handler threw. Pinned by + `cancel-while-running.int.test.ts`. The rest of that finding is recorded above as a known gap. +4. **`onFailed` stopped carrying the plugin's own error.** Payload replaces a task's error with a + message-only copy, so a host matching on the exported error classes silently stopped matching. + The handler's error is now kept, weakly keyed by the run, and handed back. Pinned by an assertion + in `gave-up-mid-run.int.test.ts`. +5. **A third-party runner could not name what the contract tells it to return.** `EnqueueAssignment` + and `Task` are now exported from `src/index.ts`, and the conformance fixture annotates with them + instead of relying on inference. +6. **`EnqueuePlan`'s "exactly one of three lists" was not enforced.** Two predicates decided + "already translated" — latest-log-wins for `queue`, any-succeeded for `covered` — so a locale + logged `[succeeded, failed]` landed in no list and was dropped from the answer. One predicate now + decides both. + +Also from that pass: four checks that would have passed without the behaviour they named — +`taskFromStored` and the `handle` branch of `taskFromHandlerInput` were untested, every +collection-status fixture shared one document id so a constant column passed, and `stillOwed` never +exercised the `running` state its own contract names. All four are now covered. + +### 2026-10-07 — sp-review-deep (focus: does the work meet #107/#110/#106/#103's real need) +- **Vectors:** core(correctness · regression · intent) + contracts-types · tests · abstractions-solid · + conventions · architecture-fit · **Rounds:** 1 parallel find round + 1 opus adversarial round + (loop-until-dry stopped after round 2: reopening count 0 → 0, no further round) +- **Findings:** 9 raised across both rounds · 6 dropped (low confidence) · 3 reported · 0 unresolved-reopening +- **Reopening per round:** 0 → 0 (converged — every surviving finding is docs-only or spec-only, none + breaks a criterion at runtime today) +- **Per-issue verdict, independently reached twice (parallel fan-out, then opus re-verification against + Payload's own compiled source):** + - **#107** — met. The flat list genuinely lets a host record a job id per document×locale row, + including the multi-document case; `planEnqueue`'s `append ∪ queue ∪ covered` was hand-verified as + a true partition of the requested locales. + - **#110** — met for run identity (the handle is structurally guaranteed to match between the enqueue + response and every callback — verified against `getRunTaskFunction.js` passing the same job object + by reference), narrowed exactly as D5 records for the attempt-counter half (no attempt field; a host + wanting live per-attempt visibility is not served, by design). + - **#106** — the supersede half has genuinely expired: verified directly (not from the contract's own + say-so) that no enqueue-time cancel-and-replace exists anywhere in `planEnqueue`/`PayloadJobsTaskRunner`, + and `PayloadJobsTaskRunner.test.ts:234`'s regression guard is unmodified by this diff. `onCancelled(task)` + without `reason` is correct, not a gap, because there is only one path left to report. + - **#103** — met; snake_case matches every other field this same HTTP surface already uses (and the + sibling `get-document-status` endpoint), so it is consistency, not a substitution. +- **Resolved (reported, fix=off so these are handoffs, not edits):** + - `README.md:419` (+4 related gaps: `onCancelled` undocumented, `/enqueue`'s `jobs` array undocumented, + `EnqueueAssignment`/`Task` missing from the exports table, no `Since v0.16.0` note anywhere) — the + stale "onFailed fires per attempt" claim directly contradicts D5 and this diff's own + `docs/DEPRECATIONS.md#on-failed-per-attempt` entry. Confidence 90. + - `PayloadJobsRunnerProvider.ts:222` — `owedIfGaveUp(job as unknown as PayloadJob, ...)` is an + unnecessary type-erasing cast (confirmed by compiling a probe: `job` is structurally assignable to + `PayloadJob` without it); a future drift between `PayloadJob` and Payload's real `Job` type would + compile silently here and could feed `owedIfGaveUp` `undefined` counters, read as `0`, falsely + reporting a final failure on the first attempt. Confidence 80. + - `src/index.ts:62` / `TaskRunner.interface.ts:34` — the published barrel describes `findByIds` as + "a `Task` per handle," but the shipped granularity (and the only correct one, given `onCancelled` + fires once per returned Task) is one `Task` per handle×locale. A third-party runner built strictly + from the barrel's wording would silently drop every locale but one from its `onCancelled` + announcements. Confidence 82. +- **Left open (dropped, low confidence <75 — reported here for the record, not acted on):** + - `wireTranslateRunner.ts:78` — a legacy pre-0.11.4 standalone-task job row (no workflow wrapper) + would never fire `onFailed` on final failure, because `reportsFinalFailure` is hardcoded `true` but + Payload's synthetic single-task wrapper has no catch to call `reportFinalFailure` from. Re-verified + independently (confidence 72): real on the facts, but reachability depends on an install still + carrying unprocessed pre-0.11.4 job rows into this upgrade — worth a one-line check before release, + not a blocking defect. + - D5a's task.md "Mechanism" section (`jobsCollectionOverrides`/`afterChange`) describes an approach + that was never built — shipped code computes finality inline in the workflow handler's own catch + instead. Confirmed independently by three reviewers. Doc-accuracy only; the acceptance criteria it + supports (6,7,7a,7b) pass via the real mechanism. (55) + - A new reverse dependency edge: `task-runner/payload-jobs-runner/owedIfGaveUp.ts` imports `stillOwed` + from `lifecycle/` — the first time task-runner has imported from lifecycle. Layering smell, no + runtime circularity. (55) + - `PayloadJobsTaskRunner.serve()`'s `handle === null` fallback is untested; existing unit tests weren't + updated for the return-type change. Judged practically unreachable — Payload's `jobs.queue()` always + resolves an id. (45) + - `needsDecoration.ts` duplicates `withQueuedNotification.ts`'s hardcoded callback list with no + structural tie — a future callback added to one without the other reproduces the bug criterion 13 + closed. Hypothetical, not present. (55) + - `thrownByTheHandler.ts` has no test file; its `recallThrown` fallback branch is dead in context at + its one call site. (40) +- **Needs-verification (handoff, never scored):** whether the consumer who filed #110 considers "onFailed + fires once, final-only, no attempt field" sufficient, given they explicitly asked for an attempt + counter and the contract records that need as deliberately unmet (Follow-ups). +- **Pin:** `3bcf7ce84574`, expires 24h diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-08-one-channel-for-task-events.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-08-one-channel-for-task-events.task.md new file mode 100644 index 000000000..914ca348c --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-08-one-channel-for-task-events.task.md @@ -0,0 +1,210 @@ +# One channel for task events + +Supersedes parts of [2026-10-06-lifecycle-run-context](./2026-10-06-lifecycle-run-context.task.md), +whose `reportsFinalFailure` / `reportFinalFailure` pair this replaces before either ships. + +## Task restatement + +Everything the plugin tells a host application currently leaves from three places: a wrapper +around the runner (`queued`, `cancelled`), a catch inside the translation handler (`completed`, +`failed`), and a callback the plugin hands the runner (`failed`, for locales a run gave up on). +A boolean on the provider — `reportsFinalFailure` — picks between the last two. + +Replace all three with one obligation on the runner: **tell the plugin what happened to each +assignment it accepted.** The plugin turns that into the host's callbacks. The runner still knows +nothing about the host. + +## The constraint that shapes it + +For the Payload-jobs runner **the plugin is not on the call stack**: Payload's scheduler calls the +registered workflow, which calls the registered task, which calls the translation function the +plugin handed over. Nothing can be returned "up" to the plugin, because what is up is Payload. So +the channel has to be a callback the plugin gives the runner, not a return value. + +## Decisions + +**D1 — one method, covering the whole life of an assignment, not only its end.** + +```ts +report(assignment: EnqueueAssignment, event: TaskEvent): Promise +``` + +*Rejected: terminal-only (`onSettled`), with the plugin reporting `queued` itself.* The plugin +cannot: the synchronous runner translates inside `enqueue`, so a `queued` raised after `enqueue` +returns would arrive after `delivered`, and one raised before it would have to be duplicated at both +call sites that enqueue (the HTTP route and the auto-translate hook). Only the runner knows the +timeline of work it accepted. **Owner's choice, taken at the Phase 2 gate.** + +**D2 — four events, not five.** + +```ts +type TaskEvent = + | { state: "queued" } + | { state: "delivered" } + | { state: "failed"; error: unknown } + | { state: "cancelled" }; +``` + +*Rejected: a fifth `abandoned` for a locale a run never reached.* The plugin maps both it and +`failed` to the host's `onFailed`, so the distinction would be discarded one line later — surplus +information in a contract third parties must implement. `failed`'s docblock states that for a locale +that never ran, the error is the one that ended the run rather than its own. Re-introducing the +distinction later is additive and cheap. + +**D3 — `EnqueueAssignment` carries the whole identity of the work.** It gains `sourceLng` and +`strategy`, so one mapper turns it into the host's `TranslationTask`. + +*Rejected: a richer argument to `report`.* The three mappers that exist today +(`taskFromInput`, `taskFromHandlerInput`, `taskFromStored`) exist precisely because the event +arrives in three shapes; one channel should mean one shape and one mapper. The two added fields are +data the runner already holds at every point it reports. The HTTP enqueue answer keeps its four +fields — the wire mapper picks, it does not mirror. + +**D4 — the runner receives the context, not just the handler.** `create(payload, handler)` becomes +`create(payload, context)`, the same `TaskRunnerContext` that `configure` already takes. + +*Rejected: a third parameter on `create`.* Both runners need `report` in two different places — the +synchronous one inside `enqueue`, the jobs one inside `cancel` and inside the registered workflow — +and `configure` already hands over a context object. One object beats a growing parameter list, and +`create`'s signature is breaking either way. + +**D5 — `findByIds` leaves the contract.** Its only consumer was the plugin's cancellation +announcement, which no longer exists: the runner knows what it still owed and reports `cancelled` +itself. **Owner's choice, taken at the Phase 2 gate.** + +**D6 — `report` is required on the context, not optional.** Optional would mean the plugin could +ship a context without it and leave every host silent, with no compiler complaint. Required makes +that a build error in every file that supplies or reads a context. + +What it does **not** do is oblige a runner to call it: no type can require that a function be +called, and the `silent` fixture in `TaskRunner.conformance.types.ts` compiles while never reporting +anything. The obligation to report lives in the contract prose and in the invariant suite, which is +where a runner's behaviour can actually be held to it. + +**D7 — five sentences the first draft left out, added before any implementation.** A blind author +writing the invariants against the contract alone found them, each as a question it could not answer +without guessing: when a report is observable (before the causing call resolves); that the reported +assignment carries the handle the enqueue answer gave; that a locale an existing run already covers +still earns its `queued`; that `cancelled` only ever follows a `cancel`; and that a locale already +reported `failed` is not owed. All five are now in `TaskRunnerContext.report`. + +Two it raised are deliberately left unstated: the order within the cancelled-then-delivered +exception, and whether `sourceLng`/`strategy` on a reported assignment must equal the request's — +the handle is the identity, and promising more would be promising what nothing needs. + +## What disappears + +| Thing | Why it can go | +|---|---| +| `TaskRunnerProvider.reportsFinalFailure` | nothing to switch between — one channel | +| `TaskRunnerContext.reportFinalFailure` | replaced by `report` | +| `withQueuedNotification.ts` and its 24 checks | the runner reports; nothing to decorate | +| `TaskRunner.findByIds` | D5 | +| the reporting catch in `runnerContext.handler` | the runner reports failure, not the handler | +| `LifecycleNotifier.cancelling` | the runner supplies the tasks; no read to own | +| two of the three mappers in `taskMapping.ts` | D3 | + +## Acceptance criteria + +| # | Criterion | How it is checked | Evidence of done | +|---|---|---|---| +| 1 | `reportsFinalFailure` exists nowhere | grep over `src` | no hits | +| 2 | `withQueuedNotification.ts` is deleted | file check | absent | +| 3 | `TaskRunner` has no `findByIds` | grep the interface | no hits | +| 4 | `taskMapping.ts` exports exactly one mapper | grep count | 1 | +| 5 | `EnqueueAssignment` carries `sourceLng` and `strategy` | type check of the fixture | compiles | +| 6 | Nothing can build a `TaskRunnerContext` without `report` | compile-only fixture + mutation under `check-types` | removing `report` from the context breaks the build wherever one is supplied or read | +| 7 | A host registering `onQueued` hears it once per requested locale, before any terminal event for that locale | integration, real database, both runners | order asserted | +| 8 | A locale that fails twice then succeeds fires `onFailed` zero times and `onCompleted` once | integration, failing provider | counts | +| 9 | A locale whose run gives up fires `onFailed` exactly once | integration | one call | +| 10 | A locale a run never reached fires `onFailed` too | integration, two locales, first fails terminally | both reported | +| 11 | Cancelling fires `onCancelled` once per locale the run still owed, and not for delivered ones | integration | set equality | +| 12 | Every assignment gets at most one terminal event — except a locale already executing when cancelled, which may still settle as delivered | invariant suite, both runners | asserted, with the exception named | +| 13 | The host-facing surface is unchanged: `TranslationLifecycleCallbacks`, `TranslationTask`, `createPayloadJobsRunner`, `createSyncRunner`, the HTTP responses | diff review + existing integration suite | no change | +| 14 | Nothing else regresses | three checks + the integration matrix, three adapters × exclusive queue | at or above baseline | + +## Criteria pre-flight — run 2026-10-08 against the untouched tree + +| # | Result now | Reading | +|---|---|---| +| 1 | fails — `reportsFinalFailure` is in `TaskRunnerProvider.interface.ts` | correct polarity | +| 2 | fails — the file exists | correct | +| 3 | fails — `findByIds` is on the interface | correct | +| 4 | fails — three mappers | correct | +| 5 | fails — neither field is on `EnqueueAssignment` | correct | +| 13, 14 | **pass** — 2027 unit / 143 files, `check-types` 0, oxlint 55-0, integration 212 on SQLite | a must-stay-true criterion, green before the change | + +## Risk + +**High.** A public contract implemented outside the package, two implementations, and every +host-visible event flows through it. Consequences, per the workflow: the gate was taken with an +explicit question, tests are required rather than optional, the Phase 4 sweep must name what was +grepped, and Phase 5 uses three review angles. + +## Human choices + +- **One channel including `queued`, not terminal-only** — D1, chosen at the gate over the plugin + reporting `queued` itself. +- **`findByIds` removed** — D5, chosen at the gate over keeping it as a capability. +- Not changing the unit of work from document to locale: rejected for this iteration, recorded in + the conversation that produced this task. +- `README.md` is the owner's; this task does not touch it. +- No tracker issues. + +## Known gap, carried forward + +Cancelling does not stop a locale already being translated: it is reported `cancelled` and may then +settle `delivered`, so that one locale produces two events. Criterion 12 names the exception rather +than pretending otherwise. Closing it means changing what cancellation does, which is its own work. + +## Review log + +_(appended by each review pass)_ + +### 2026-10-09 · phase 5 verification + +- **Checks:** unit `bunx vitest run` 2017/143 files · `turbo run check-types --force` 9/9 · + `bunx oxlint` 0 errors · `turbo run build --filter='./packages/*'` green, including the real + `tsc` declaration build · integration six combinations, three adapters x Payload's exclusive queue on and off: + SQLite 224/7 skipped, PostgreSQL 229/2, MongoDB 222/9 (231 each), no failures +- **Gates:** `sp-diff-checks` clean over 67 files (+4020/−463), five checks, no findings; + `sp-lint-delta --base main` (mode=two-run) nothing introduced — one warning it did find, an unused + `assigned` binding in `SyncTaskRunner.test.ts`, was removed. oxlint's default output is not + parseable by the delta script; the gate was re-run with `--format=unix` to get a real subtraction. +- **Reviewers:** correctness · regression · intent (three, as High risk requires; the catalog has no + `tests` vector, so its question — would these checks fail if the code were wrong — was folded into + the correctness brief). Five findings raised, five acted on. + 1. *correctness* — `cancel` re-announced a locale the run had already reported `failed`. + `stillOwed` keeps every locale that is not `completed`, and a run that gives up keeps + `hasError` with no `completedAt`, so its settled locales still read as owed. Reproduced on + SQLite before any edit. Fixed by `owedOnCancel`, which answers the cancel path's own question: + a spent run owes nothing, a run waiting to retry owes its undelivered locales. + **Not taken: the reviewer's one-line fix** (`status !== "failed"`), which would have silenced + the legitimate case — a job between attempts logs every locale as failed and those locales are + genuinely still owed. `owedOnCancel.test.ts` pins both directions. + **Not changed: `cancel-by-collection/handler.ts`**, named as a sibling. Its filter is right; + whether a run has given up is the runner's knowledge, so the guard belongs where it now is. The + integration suite exercises that endpoint too. + 2. *intent* — criterion 7 had no check at all: no integration file registered `onQueued`. + Written now, one per runner (`queued-before-any-ending.int.test.ts`, + `queued-before-any-ending-on-jobs.int.test.ts`), each mutation-proved. + 3. *regression* — `TranslationTask.handle` documented itself as absent on `onQueued`. False since + the runner became what reports `queued`: every callback carries the handle. Rewritten. + 4. *regression* — `wireTranslateRunner`'s docblock still described the deleted decoration. + 5. *regression* — `DEPRECATIONS.md` pointed a reader at the removed `reportsFinalFailure`; it now + points at `report`'s `failed` event. One stale code-ref line beside it was corrected too. + Considered and not acted on: a cron racing the end-of-`enqueue` `queued` loop. The contract + promises `queued` before `enqueue` resolves, not before anything external can run, and the new + jobs-runner check pins exactly that promise. +- **Mutations:** removing `report` from `TaskRunnerContext` → build fails in 9 files · narrowing + `enqueue` to require assignments → the conformance fixture alone fails · reporting `queued` after + the work in `SyncTaskRunner` → "de ended before the host was told it had started" · deleting the + jobs runner's `queued` loop → both new jobs checks red · `owedOnCancel` without the give-up guard + → the cancel-after-give-up checks red. Every mutation restored and the restore verified. +- **Criteria:** 13 met · 1 restated. Criterion 6 as written ("a runner that does not implement + `report` fails to build") is false and the repository holds its counter-example: the `silent` + fixture never calls `report` and compiles, because no type can require that a function be called. + The criterion and D6 now claim what the type actually enforces — that nothing can build a context + without `report` — and the fixture's docblock says the same. +- **Left open:** `README.md`'s lifecycle section still describes the per-attempt behaviour; it is the + owner's to edit. The handle-reuse limit on SQLite stays named in the contract rather than fixed. diff --git a/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-06-alreadyCovered-red-run.txt b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-06-alreadyCovered-red-run.txt new file mode 100644 index 000000000..c5a59a837 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-06-alreadyCovered-red-run.txt @@ -0,0 +1,193 @@ + + RUN v3.2.4 /Users/FocusReactive/Documents/Projects/payload-plugins/packages/payload-plugin-translator + + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts (12 tests | 12 failed) 7ms + × alreadyCovered > a locale the run lists and has not translated > covers it when it has not been attempted, while another locale of the same run is translated 3ms + → not implemented — red run + × alreadyCovered > a locale the run lists and has not translated > covers it when its attempt failed, because the run will come back to it 0ms + → not implemented — red run + × alreadyCovered > a locale the run lists and has translated > does not cover it, while the run still has an unreached locale 0ms + → not implemented — red run + × alreadyCovered > several records for one locale > does not cover a locale that failed and was then translated 0ms + → not implemented — red run + × alreadyCovered > several records for one locale > does not cover a locale that was translated and has a later failure 0ms + → not implemented — red run + × alreadyCovered > a record that names no locale > ignores it, so the locale it cannot identify stays covered 0ms + → not implemented — red run + × alreadyCovered > a locale the run does not list > does not cover it, though the run has locales it has not reached 0ms + → not implemented — red run + × alreadyCovered > a locale asked for twice > answers it once 0ms + → not implemented — red run + × alreadyCovered > locales the request did not ask for > leaves out a locale the run carries and has not reached but nobody requested 0ms + → not implemented — red run + × alreadyCovered > order > follows the request, not the order the run lists its locales in 0ms + → not implemented — red run + × alreadyCovered > empty request > yields nothing, though the run carries locales it has not reached 0ms + → not implemented — red run + × alreadyCovered > a run carrying no locales > yields nothing 0ms + → not implemented — red run + +⎯⎯⎯⎯⎯⎯ Failed Tests 12 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > a locale the run lists and has not translated > covers it when it has not been attempted, while another locale of the same run is translated +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:36:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > a locale the run lists and has not translated > covers it when its attempt failed, because the run will come back to it +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:41:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > a locale the run lists and has translated > does not cover it, while the run still has an unreached locale +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:48:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > several records for one locale > does not cover a locale that failed and was then translated +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:58:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > several records for one locale > does not cover a locale that was translated and has a later failure +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:66:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > a record that names no locale > ignores it, so the locale it cannot identify stays covered +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:79:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > a locale the run does not list > does not cover it, though the run has locales it has not reached +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:86:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > a locale asked for twice > answers it once +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:93:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > locales the request did not ask for > leaves out a locale the run carries and has not reached but nobody requested +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:100:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > order > follows the request, not the order the run lists its locales in +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:107:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > empty request > yields nothing, though the run carries locales it has not reached +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:114:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/12]⎯ + + FAIL src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts > alreadyCovered > a run carrying no locales > yields nothing +Error: not implemented — red run + ❯ alreadyCovered src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts:43:9 + 41| + 42| export const alreadyCovered: AlreadyCovered = () => { + 43| throw new Error("not implemented — red run"); + | ^ + 44| }; + 45| + ❯ src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts:121:14 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/12]⎯ + + + Test Files 1 failed (1) + Tests 12 failed (12) + Start at 19:38:37 + Duration 396ms (transform 45ms, setup 0ms, collect 30ms, tests 7ms, environment 0ms, prepare 65ms) + diff --git a/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-06-stillOwed-red-run.txt b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-06-stillOwed-red-run.txt new file mode 100644 index 000000000..1e8ed72cf --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-06-stillOwed-red-run.txt @@ -0,0 +1,133 @@ + + RUN v3.2.4 /Users/FocusReactive/Documents/Projects/payload-plugins/packages/payload-plugin-translator + + ❯ src/server/modules/lifecycle/stillOwed.test.ts (8 tests | 8 failed) 6ms + × stillOwed > owes nothing when every locale was delivered 3ms + → not implemented — red run + × stillOwed > owes a locale that was attempted and failed 0ms + → not implemented — red run + × stillOwed > owes a locale the run never reached 0ms + → not implemented — red run + × stillOwed > does not announce a delivered locale, even though the run ended badly 0ms + → not implemented — red run + × stillOwed > owes each locale separately, although the run's locales share one id 0ms + → not implemented — red run + × stillOwed > orders the owed locales the way the run would have translated them 0ms + → not implemented — red run + × stillOwed > hands back the task itself, not a locale name or a runner's row 0ms + → not implemented — red run + × stillOwed > owes nothing for a run that covered nothing 0ms + → not implemented — red run + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 8 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL src/server/modules/lifecycle/stillOwed.test.ts > stillOwed > owes nothing when every locale was delivered +Error: not implemented — red run + ❯ stillOwed src/server/modules/lifecycle/stillOwed.ts:31:9 + 29| + 30| export const stillOwed: StillOwed = () => { + 31| throw new Error("not implemented — red run"); + | ^ + 32| }; + 33| + ❯ src/server/modules/lifecycle/stillOwed.test.ts:47:12 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/8]⎯ + + FAIL src/server/modules/lifecycle/stillOwed.test.ts > stillOwed > owes a locale that was attempted and failed +Error: not implemented — red run + ❯ stillOwed src/server/modules/lifecycle/stillOwed.ts:31:9 + 29| + 30| export const stillOwed: StillOwed = () => { + 31| throw new Error("not implemented — red run"); + | ^ + 32| }; + 33| + ❯ src/server/modules/lifecycle/stillOwed.test.ts:53:12 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/8]⎯ + + FAIL src/server/modules/lifecycle/stillOwed.test.ts > stillOwed > owes a locale the run never reached +Error: not implemented — red run + ❯ stillOwed src/server/modules/lifecycle/stillOwed.ts:31:9 + 29| + 30| export const stillOwed: StillOwed = () => { + 31| throw new Error("not implemented — red run"); + | ^ + 32| }; + 33| + ❯ src/server/modules/lifecycle/stillOwed.test.ts:59:12 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/8]⎯ + + FAIL src/server/modules/lifecycle/stillOwed.test.ts > stillOwed > does not announce a delivered locale, even though the run ended badly +Error: not implemented — red run + ❯ stillOwed src/server/modules/lifecycle/stillOwed.ts:31:9 + 29| + 30| export const stillOwed: StillOwed = () => { + 31| throw new Error("not implemented — red run"); + | ^ + 32| }; + 33| + ❯ src/server/modules/lifecycle/stillOwed.test.ts:68:7 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/8]⎯ + + FAIL src/server/modules/lifecycle/stillOwed.test.ts > stillOwed > owes each locale separately, although the run's locales share one id +Error: not implemented — red run + ❯ stillOwed src/server/modules/lifecycle/stillOwed.ts:31:9 + 29| + 30| export const stillOwed: StillOwed = () => { + 31| throw new Error("not implemented — red run"); + | ^ + 32| }; + 33| + ❯ src/server/modules/lifecycle/stillOwed.test.ts:73:12 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/8]⎯ + + FAIL src/server/modules/lifecycle/stillOwed.test.ts > stillOwed > orders the owed locales the way the run would have translated them +Error: not implemented — red run + ❯ stillOwed src/server/modules/lifecycle/stillOwed.ts:31:9 + 29| + 30| export const stillOwed: StillOwed = () => { + 31| throw new Error("not implemented — red run"); + | ^ + 32| }; + 33| + ❯ src/server/modules/lifecycle/stillOwed.test.ts:77:18 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/8]⎯ + + FAIL src/server/modules/lifecycle/stillOwed.test.ts > stillOwed > hands back the task itself, not a locale name or a runner's row +Error: not implemented — red run + ❯ stillOwed src/server/modules/lifecycle/stillOwed.ts:31:9 + 29| + 30| export const stillOwed: StillOwed = () => { + 31| throw new Error("not implemented — red run"); + | ^ + 32| }; + 33| + ❯ src/server/modules/lifecycle/stillOwed.test.ts:85:12 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/8]⎯ + + FAIL src/server/modules/lifecycle/stillOwed.test.ts > stillOwed > owes nothing for a run that covered nothing +Error: not implemented — red run + ❯ stillOwed src/server/modules/lifecycle/stillOwed.ts:31:9 + 29| + 30| export const stillOwed: StillOwed = () => { + 31| throw new Error("not implemented — red run"); + | ^ + 32| }; + 33| + ❯ src/server/modules/lifecycle/stillOwed.test.ts:89:12 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/8]⎯ + + + Test Files 1 failed (1) + Tests 8 failed (8) + Start at 18:58:26 + Duration 326ms (transform 34ms, setup 0ms, collect 29ms, tests 6ms, environment 0ms, prepare 60ms) + diff --git a/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-07-TaskRunner-invariants-red-run.txt b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-07-TaskRunner-invariants-red-run.txt new file mode 100644 index 000000000..373407700 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-07-TaskRunner-invariants-red-run.txt @@ -0,0 +1,339 @@ + + RUN v3.2.4 /Users/FocusReactive/Documents/Projects/payload-plugins/packages/payload-plugin-translator + + ❯ src/server/modules/task-runner/__stubs__/red-run.test.ts (21 tests | 21 failed) 10ms + × stub — red run > an empty request is answered, not refused > answers an empty enqueue 4ms + → not implemented — red run + × stub — red run > an empty request is answered, not refused > answers an empty cancel 0ms + → not implemented — red run + × stub — red run > an empty request is answered, not refused > answers an empty findByIds 0ms + → not implemented — red run + × stub — red run > an empty request is answered, not refused > answers about no documents, not about all of them 0ms + → not implemented — red run + × stub — red run > a handle is a non-empty string > names each assignment with a non-empty handle 0ms + → not implemented — red run + × stub — red run > a handle is a non-empty string > takes its own handles back 0ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers only locales the request listed 0ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers a locale at most once per document 0ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers once although the request listed a locale twice 0ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers an assignment per requested locale 1ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers a locale an existing run already covers 0ms + → not implemented — red run + × stub — red run > an unknown handle is an answer, not a failure > answers run on an unknown handle with not_found 0ms + → not implemented — red run + × stub — red run > an unknown handle is an answer, not a failure > resolves cancel on an unknown handle 0ms + → not implemented — red run + × stub — red run > an unknown handle is an answer, not a failure > cancels nothing when the handle is unknown 0ms + → not implemented — red run + × stub — red run > findByIds resolves handles to the tasks they stand for > answers only tasks for the handles asked about 0ms + → not implemented — red run + × stub — red run > findByIds resolves handles to the tasks they stand for > announces each target locale of a handle separately 0ms + → not implemented — red run + × stub — red run > findByIds resolves handles to the tasks they stand for > announces a target locale once per handle 0ms + → not implemented — red run + × stub — red run > findByCollection finds tasks for a collection > answers only tasks of the collection asked about 0ms + → not implemented — red run + × stub — red run > findByCollection finds tasks for a collection > keeps only the documents the filter names 0ms + → not implemented — red run + × stub — red run > findByCollection finds tasks for a collection > keeps only the documents the deprecated array names 0ms + → not implemented — red run + × stub — red run > findByCollection finds tasks for a collection > drops tasks that have finished 0ms + → not implemented — red run + +⎯⎯⎯⎯⎯⎯ Failed Tests 21 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > answers an empty enqueue +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:57:46 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > answers an empty cancel +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:63:46 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > answers an empty findByIds +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:75:39 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > answers about no documents, not about all of them +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:81:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > a handle is a non-empty string > names each assignment with a non-empty handle +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:87:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > a handle is a non-empty string > takes its own handles back +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:99:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers only locales the request listed +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:117:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers a locale at most once per document +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:130:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers once although the request listed a locale twice +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:144:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers an assignment per requested locale +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:157:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers a locale an existing run already covers +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:173:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an unknown handle is an answer, not a failure > answers run on an unknown handle with not_found +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:189:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an unknown handle is an answer, not a failure > resolves cancel on an unknown handle +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:194:31 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an unknown handle is an answer, not a failure > cancels nothing when the handle is unknown +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:201:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByIds resolves handles to the tasks they stand for > answers only tasks for the handles asked about +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:218:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByIds resolves handles to the tasks they stand for > announces each target locale of a handle separately +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:232:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByIds resolves handles to the tasks they stand for > announces a target locale once per handle +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:51:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:250:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByCollection finds tasks for a collection > answers only tasks of the collection asked about +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:267:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByCollection finds tasks for a collection > keeps only the documents the filter names +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:278:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByCollection finds tasks for a collection > keeps only the documents the deprecated array names +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:289:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[20/21]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByCollection finds tasks for a collection > drops tasks that have finished +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:300:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[21/21]⎯ + + + Test Files 1 failed (1) + Tests 21 failed (21) + Start at 19:43:58 + Duration 332ms (transform 47ms, setup 0ms, collect 37ms, tests 10ms, environment 0ms, prepare 59ms) + diff --git a/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-08-task-events-red-run.txt b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-08-task-events-red-run.txt new file mode 100644 index 000000000..506b81f90 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/artifacts/2026-10-08-task-events-red-run.txt @@ -0,0 +1,465 @@ + + RUN v3.2.4 /Users/FocusReactive/Documents/Projects/payload-plugins/packages/payload-plugin-translator + + ❯ src/server/modules/task-runner/__stubs__/red-run.test.ts (29 tests | 29 failed) 11ms + × stub — red run > an empty request is answered, not refused > answers an empty enqueue 4ms + → not implemented — red run + × stub — red run > an empty request is answered, not refused > answers an empty cancel 0ms + → not implemented — red run + × stub — red run > an empty request is answered, not refused > reports nothing about an empty enqueue 0ms + → not implemented — red run + × stub — red run > an empty request is answered, not refused > reports nothing about an empty cancel 0ms + → not implemented — red run + × stub — red run > an empty request is answered, not refused > answers about no documents, not about all of them 0ms + → not implemented — red run + × stub — red run > a handle is a non-empty string > names each assignment with a non-empty handle 0ms + → not implemented — red run + × stub — red run > a handle is a non-empty string > takes its own handles back 1ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers only locales the request listed 0ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers a locale at most once per document 0ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers once although the request listed a locale twice 0ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers an assignment per requested locale 0ms + → not implemented — red run + × stub — red run > only what was asked for is answered > answers a locale an existing run already covers 0ms + → not implemented — red run + × stub — red run > what became of every accepted assignment reaches the plugin > reports queued for every requested locale 0ms + → not implemented — red run + × stub — red run > what became of every accepted assignment reaches the plugin > reports queued at most once per requested locale 0ms + → not implemented — red run + × stub — red run > what became of every accepted assignment reaches the plugin > reports queued once although the request listed a locale twice 0ms + → not implemented — red run + × stub — red run > what became of every accepted assignment reaches the plugin > reports only about locales the request listed 0ms + → not implemented — red run + × stub — red run > what became of every accepted assignment reaches the plugin > reports no terminal event for a locale before its queued 0ms + → not implemented — red run + × stub — red run > what became of every accepted assignment reaches the plugin > reports at most one terminal event per assignment 0ms + → not implemented — red run + × stub — red run > cancelling reports what the run still owed > reports cancelled for every locale the run still owed 0ms + → not implemented — red run + × stub — red run > cancelling reports what the run still owed > reports cancelled for no locale the run had delivered 0ms + → not implemented — red run + × stub — red run > cancelling reports what the run still owed > reports at most one terminal event per cancelled assignment 0ms + → not implemented — red run + × stub — red run > cancelling reports what the run still owed > reports nothing cancelled about a run whose handle it was not given 0ms + → not implemented — red run + × stub — red run > a handle that no longer names anything is an answer, not a failure > answers run on a spent handle with not_found 0ms + → not implemented — red run + × stub — red run > a handle that no longer names anything is an answer, not a failure > resolves cancel on a spent handle 0ms + → not implemented — red run + × stub — red run > a handle that no longer names anything is an answer, not a failure > reports nothing about a live run when a spent handle is cancelled 0ms + → not implemented — red run + × stub — red run > findByCollection finds tasks for a collection > answers only tasks of the collection asked about 0ms + → not implemented — red run + × stub — red run > findByCollection finds tasks for a collection > keeps only the documents the filter names 0ms + → not implemented — red run + × stub — red run > findByCollection finds tasks for a collection > keeps only the documents the deprecated array names 0ms + → not implemented — red run + × stub — red run > findByCollection finds tasks for a collection > drops tasks that have finished 0ms + → not implemented — red run + +⎯⎯⎯⎯⎯⎯ Failed Tests 29 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > answers an empty enqueue +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:127:46 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > answers an empty cancel +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:133:46 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > reports nothing about an empty enqueue +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:139:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > reports nothing about an empty cancel +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:145:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > an empty request is answered, not refused > answers about no documents, not about all of them +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:151:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > a handle is a non-empty string > names each assignment with a non-empty handle +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:157:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > a handle is a non-empty string > takes its own handles back +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:169:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers only locales the request listed +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:185:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers a locale at most once per document +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:198:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers once although the request listed a locale twice +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:212:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers an assignment per requested locale +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:226:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > only what was asked for is answered > answers a locale an existing run already covers +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:242:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > what became of every accepted assignment reaches the plugin > reports queued for every requested locale +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:260:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > what became of every accepted assignment reaches the plugin > reports queued at most once per requested locale +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:271:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > what became of every accepted assignment reaches the plugin > reports queued once although the request listed a locale twice +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:282:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > what became of every accepted assignment reaches the plugin > reports only about locales the request listed +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:294:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > what became of every accepted assignment reaches the plugin > reports no terminal event for a locale before its queued +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:303:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > what became of every accepted assignment reaches the plugin > reports at most one terminal event per assignment +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:323:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > cancelling reports what the run still owed > reports cancelled for every locale the run still owed +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:333:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > cancelling reports what the run still owed > reports cancelled for no locale the run had delivered +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:354:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[20/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > cancelling reports what the run still owed > reports at most one terminal event per cancelled assignment +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:368:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[21/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > cancelling reports what the run still owed > reports nothing cancelled about a run whose handle it was not given +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:386:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[22/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > a handle that no longer names anything is an answer, not a failure > answers run on a spent handle with not_found +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ spent src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:415:30 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:422:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[23/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > a handle that no longer names anything is an answer, not a failure > resolves cancel on a spent handle +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ spent src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:415:30 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:432:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[24/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > a handle that no longer names anything is an answer, not a failure > reports nothing about a live run when a spent handle is cancelled +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ enqueued src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:85:35 + ❯ spent src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:415:30 + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:445:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[25/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByCollection finds tasks for a collection > answers only tasks of the collection asked about +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:468:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[26/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByCollection finds tasks for a collection > keeps only the documents the filter names +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:480:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[27/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByCollection finds tasks for a collection > keeps only the documents the deprecated array names +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:492:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[28/29]⎯ + + FAIL src/server/modules/task-runner/__stubs__/red-run.test.ts > stub — red run > findByCollection finds tasks for a collection > drops tasks that have finished +Error: not implemented — red run + ❯ Object.notImplemented src/server/modules/task-runner/__stubs__/TaskRunner.stub.ts:4:9 + 2| + 3| const notImplemented = (): never => { + 4| throw new Error("not implemented — red run"); + | ^ + 5| }; + 6| + ❯ src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts:503:22 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[29/29]⎯ + + + Test Files 1 failed (1) + Tests 29 failed (29) + Start at 18:44:09 + Duration 341ms (transform 49ms, setup 0ms, collect 49ms, tests 11ms, environment 0ms, prepare 66ms) + diff --git a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts index 008c6d546..5511c322b 100644 --- a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts +++ b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi } from "vitest"; -import type { Config, Payload } from "payload"; +import type { Config, Payload, CollectionSlug } from "payload"; +import type { TaskRunnerContext } from "../server/modules/task-runner/index.js"; +import type { TaskEvent } from "../server/modules/task-runner/types.js"; +import type { TranslationLifecycleCallbacks } from "../server/modules/lifecycle/types.js"; import { translatorPlugin } from "../plugin.js"; import { AnyAccessGuard } from "../server/shared/access/AnyAccessGuard.js"; @@ -8,7 +11,6 @@ import { withAutoTranslate } from "../auto-translate-config.js"; import { documentLevel, fieldLevel } from "../composition/levels/index.js"; import { TranslateDocumentExport } from "../client/widgets/translate-document/index.js"; import { BulkDocumentTranslationDashboard } from "../client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.js"; -import { withQueuedNotification } from "../server/modules/lifecycle/index.js"; // Isolate the lifecycle-wiring tests from the real pipeline: a mocked translateContent that returns // null makes the handler finish cleanly (nothing to translate) so we can assert `completed` fires @@ -18,16 +20,6 @@ vi.mock("../core/translation-pipeline/index.js", () => ({ translateContent: vi.fn().mockResolvedValue(null), })); -// Spy on withQueuedNotification while keeping its real behaviour, so a test can assert the runner -// was (or wasn't) wrapped for the `lifecycle.onQueued` absent/present branch in plugin.ts. -vi.mock("../server/modules/lifecycle/index.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - withQueuedNotification: vi.fn(actual.withQueuedNotification), - }; -}); - // End-to-end behaviour guard for the levels refactor: the default levels must // reproduce today's wiring (6-route bundle, cache provider, doc popup + bulk // dashboard on managed collections, runner configured once), and an explicit @@ -355,56 +347,6 @@ describe("translatorPlugin — lifecycle callbacks", () => { collections: { posts: { config: { versions: undefined } } }, }) as unknown as Payload; - it("fires lifecycle.onCompleted after a successful task", async () => { - const onCompleted = vi.fn(); - const { runner } = await build({ - lifecycle: { onCompleted }, - } as Partial); - - await capturedHandler(runner)(mockPayload(), handlerInput()); - - expect(onCompleted).toHaveBeenCalledWith( - expect.objectContaining({ - collection: "posts", - id: "doc-1", - sourceLng: "en", - targetLng: "de", - }) - ); - }); - - it("fires lifecycle.onFailed with the error and rethrows so the runner marks it failed", async () => { - const onFailed = vi.fn(); - const { runner } = await build({ lifecycle: { onFailed } } as Partial); - - // an unknown collection makes the handler throw before translating - let rejectedError: unknown; - try { - await capturedHandler(runner)(mockPayload(), handlerInput({ collection: "unknown" })); - } catch (error) { - rejectedError = error; - } - - expect(rejectedError).toBeInstanceOf(Error); - expect(onFailed).toHaveBeenCalledWith( - expect.objectContaining({ collection: "unknown" }), - rejectedError - ); - // the rejected error and the error handed to onFailed must be the identical object - expect(onFailed.mock.calls[0][1]).toBe(rejectedError); - }); - - it("rethrows the original translation error even when onFailed itself throws", async () => { - const onFailed = vi.fn(() => { - throw new Error("callback boom"); - }); - const { runner } = await build({ lifecycle: { onFailed } } as Partial); - - await expect( - capturedHandler(runner)(mockPayload(), handlerInput({ collection: "unknown" })) - ).rejects.toThrow(/not found in schemaMap/u); - }); - it("does not fail the task when a lifecycle callback throws (swallow + log)", async () => { const onCompleted = vi.fn(() => { throw new Error("callback boom"); @@ -416,104 +358,92 @@ describe("translatorPlugin — lifecycle callbacks", () => { await expect(capturedHandler(runner)(mockPayload(), handlerInput())).resolves.toBeUndefined(); }); - it("fires lifecycle.onQueued for each task when enqueuing", async () => { - const onQueued = vi.fn(); - const taskRunner = { - enqueue: vi.fn().mockResolvedValue(undefined), - cancel: vi.fn(), - run: vi.fn(), - findByCollection: vi.fn(), - }; - const runner = { - create: vi.fn().mockReturnValue(taskRunner), - configure: vi.fn().mockReturnValue((c: Config) => c), - }; - const { result } = await build({ - runner, - lifecycle: { onQueued }, - } as unknown as Partial); - - const enqueue = result.endpoints?.find((e) => e.path === "/translate/enqueue"); - const req = { - json: async () => ({ - source_lng: "en", - target_lng: "de", - collection_slug: "posts", - collection_id: ["1", "2"], - }), - payload: { - config: { localization: { locales: ["en", "de", "fr"] } }, - logger: { error: vi.fn() }, - }, - }; - await enqueue?.handler?.(req as never); + describe("the handler translates and reports nothing", () => { + it("lets a translation failure out, so the runner can see it", async () => { + const { runner } = await build({ lifecycle: {} } as Partial); - expect(onQueued).toHaveBeenCalledTimes(2); - expect(onQueued).toHaveBeenCalledWith(expect.objectContaining({ id: "1" })); - expect(taskRunner.enqueue).toHaveBeenCalled(); + await expect( + capturedHandler(runner)(mockPayload(), handlerInput({ collection: "unknown" })), + "the runner decides what a throw means — it cannot if the handler swallows it" + ).rejects.toBeDefined(); + }); + + it.each([ + ["onCompleted", "posts"], + ["onFailed", "unknown"], + ])("does not call %s itself", async (callbackName, collection) => { + const callback = vi.fn(); + const { runner } = await build({ + lifecycle: { [callbackName]: callback }, + } as Partial); + + await capturedHandler(runner)(mockPayload(), handlerInput({ collection })).catch( + () => undefined + ); + + expect( + callback, + "reporting is the runner's through `report`; a second road would double every event" + ).not.toHaveBeenCalled(); + }); }); - it("does not wrap the runner in withQueuedNotification when lifecycle.onQueued is absent", async () => { - vi.mocked(withQueuedNotification).mockClear(); - const taskRunner = { - enqueue: vi.fn().mockResolvedValue(undefined), - cancel: vi.fn(), - run: vi.fn(), - findByCollection: vi.fn(), + describe("the report channel is the only road to the host", () => { + const assignment = { + collectionSlug: "posts" as CollectionSlug, + collectionId: "doc-1", + sourceLng: "en", + targetLng: "de", + strategy: "overwrite", + handle: "run-1", }; - const runner = { - create: vi.fn().mockReturnValue(taskRunner), - configure: vi.fn().mockReturnValue((c: Config) => c), - }; - const { result } = await build({ - runner, - lifecycle: { onCompleted: vi.fn() }, - } as unknown as Partial); - - const enqueue = result.endpoints?.find((e) => e.path === "/translate/enqueue"); - const req = { - json: async () => ({ - source_lng: "en", - target_lng: "de", - collection_slug: "posts", - collection_id: ["1", "2"], - }), - payload: { - config: { localization: { locales: ["en", "de", "fr"] } }, - logger: { error: vi.fn() }, - }, + + const contextHandedToTheRunner = async (lifecycle: Record) => { + let captured: TaskRunnerContext | undefined; + const runner = { + create: vi.fn().mockReturnValue({ + enqueue: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn(), + run: vi.fn(), + findByCollection: vi.fn(), + }), + configure: vi.fn((context: TaskRunnerContext) => { + captured = context; + return (c: Config) => c; + }), + }; + await build({ runner, lifecycle } as unknown as Partial); + return captured as TaskRunnerContext; }; - await enqueue?.handler?.(req as never); - expect(withQueuedNotification).not.toHaveBeenCalled(); - expect(taskRunner.enqueue).toHaveBeenCalled(); - }); + const payload = { logger: { error: vi.fn() } } as unknown as Payload; - it("does not fire onFailed on the success path when both onCompleted and onFailed are registered", async () => { - const onCompleted = vi.fn(); - const onFailed = vi.fn(); - const { runner } = await build({ - lifecycle: { onCompleted, onFailed }, - } as Partial); + const mappings: Array<[string, keyof TranslationLifecycleCallbacks, TaskEvent]> = [ + ["queued", "onQueued", { state: "queued" }], + ["delivered", "onCompleted", { state: "delivered" }], + ["failed", "onFailed", { state: "failed", error: new Error("provider down") }], + ["cancelled", "onCancelled", { state: "cancelled" }], + ]; - await capturedHandler(runner)(mockPayload(), handlerInput()); + it.each(mappings)("turns %s into %s", async (_label, callbackName, event) => { + const callback = vi.fn(); + const context = await contextHandedToTheRunner({ [callbackName]: callback }); - expect(onCompleted).toHaveBeenCalledTimes(1); - expect(onFailed).not.toHaveBeenCalled(); - }); + await context.report(payload, assignment, event); - it("does not fire onCompleted on the failure path when both onCompleted and onFailed are registered", async () => { - const onCompleted = vi.fn(); - const onFailed = vi.fn(); - const { runner } = await build({ - lifecycle: { onCompleted, onFailed }, - } as Partial); + expect(callback, `${String(event)} must reach ${callbackName}`).toHaveBeenCalledWith( + expect.objectContaining({ collection: "posts", id: "doc-1", targetLng: "de" }), + ...(callbackName === "onFailed" ? [expect.any(Error)] : []) + ); + }); - await expect( - capturedHandler(runner)(mockPayload(), handlerInput({ collection: "unknown" })) - ).rejects.toThrow(); + it("tells a host nothing it did not register for", async () => { + const onCompleted = vi.fn(); + const context = await contextHandedToTheRunner({ onCompleted }); - expect(onFailed).toHaveBeenCalledTimes(1); - expect(onCompleted).not.toHaveBeenCalled(); + await context.report(payload, assignment, { state: "cancelled" }); + + expect(onCompleted, "a cancellation is not a completion").not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts index 26d4368f7..91e4cbc36 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts @@ -9,7 +9,7 @@ import { isCollectionAvailable, getAllCollectionIds } from "../_lib/collection-u import { Locales } from "../../../core/domain/locales/index.js"; -import { EnqueueInputSchema } from "./model.js"; +import { EnqueueInputSchema, toAssignmentOutputs } from "./model.js"; import type { EnqueueConfig } from "./model.js"; export class EnqueueTranslationHandler { @@ -90,11 +90,15 @@ export class EnqueueTranslationHandler { })) ); - await runner.enqueue( + const assigned = await runner.enqueue( tasks, identityOf(req, authCollectionsOf(req.payload), req.payload.logger) ); - return ServerResponse.success({ success: true, queued: tasks.length }); + return ServerResponse.success({ + success: true, + queued: tasks.length, + ...(assigned ? { assignments: toAssignmentOutputs(assigned) } : {}), + }); } } diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/model.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/model.ts index d1b0718ee..e33cbe518 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/model.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/model.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import type { CollectionSlug } from "payload"; +import type { EnqueueAssignment } from "../../modules/task-runner/types.js"; + /** * Input validation schema */ @@ -22,3 +24,19 @@ export const EnqueueInputSchema = z.object({ export type EnqueueConfig = { availableCollections: Set; }; + +/** One enqueued locale, in the snake_case this surface answers in. */ +export type EnqueueAssignmentOutput = { + collection_slug: string; + collection_id: string; + target_lng: string; + handle: string; +}; + +export const toAssignmentOutputs = (assigned: EnqueueAssignment[]): EnqueueAssignmentOutput[] => + assigned.map((entry) => ({ + collection_slug: entry.collectionSlug, + collection_id: entry.collectionId, + target_lng: entry.targetLng, + handle: entry.handle, + })); diff --git a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts index a3c2e9fd5..9b357f872 100644 --- a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts @@ -15,14 +15,18 @@ describe("GetCollectionStatusHandler", () => { let mockTaskRunnerFactory: TaskRunnerFactory; let config: GetCollectionStatusConfig; - const createMockTask = (overrides: Partial = {}): Task => ({ + const createMockTask = ({ + targetLng = "de", + collectionId = "doc-123", + ...overrides + }: Partial & { targetLng?: string; collectionId?: string } = {}): Task => ({ id: "task-123", status: "completed", input: { collectionSlug: "posts" as CollectionSlug, - collectionId: "doc-123", + collectionId, sourceLng: "en", - targetLng: "de", + targetLng, strategy: "overwrite", publishOnTranslation: false, }, @@ -122,9 +126,9 @@ describe("GetCollectionStatusHandler", () => { it("returns task statuses for all documents in collection", async () => { const tasks = [ - createMockTask({ id: "task-1", status: "completed" }), - createMockTask({ id: "task-2", status: "pending" }), - createMockTask({ id: "task-3", status: "running" }), + createMockTask({ id: "run-1", status: "completed", targetLng: "de" }), + createMockTask({ id: "run-1", status: "pending", targetLng: "fr" }), + createMockTask({ id: "run-2", status: "running", targetLng: "de", collectionId: "doc-9" }), ]; (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue(tasks); @@ -133,10 +137,13 @@ describe("GetCollectionStatusHandler", () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.data.docs).toEqual([ - { id: "task-1", status: "completed" }, - { id: "task-2", status: "pending" }, - { id: "task-3", status: "running" }, + expect( + body.data.docs, + "two of these share a run, so the handle alone cannot tell them apart" + ).toEqual([ + { id: "run-1", status: "completed", collection_id: "doc-123", target_lng: "de" }, + { id: "run-1", status: "pending", collection_id: "doc-123", target_lng: "fr" }, + { id: "run-2", status: "running", collection_id: "doc-9", target_lng: "de" }, ]); }); diff --git a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.ts b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.ts index f46e9a8a3..d6c70db42 100644 --- a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.ts @@ -39,7 +39,12 @@ export class GetCollectionStatusHandler { return ServerResponse.success({ docs: tasks .filter((task) => visible.has(String(task.input.collectionId))) - .map((task) => ({ id: task.id, status: task.status })), + .map((task) => ({ + id: task.id, + status: task.status, + collection_id: String(task.input.collectionId), + target_lng: task.input.targetLng, + })), }); } } diff --git a/packages/payload-plugin-translator/src/server/features/get-collection-status/model.ts b/packages/payload-plugin-translator/src/server/features/get-collection-status/model.ts index 7288245d0..69fe0e87c 100644 --- a/packages/payload-plugin-translator/src/server/features/get-collection-status/model.ts +++ b/packages/payload-plugin-translator/src/server/features/get-collection-status/model.ts @@ -13,11 +13,14 @@ export const GetCollectionStatusInputSchema = z.object({ export type GetCollectionStatusInput = z.infer; /** - * Summary item for a single document + * One translation in flight. `id` is the run's handle and a run covers a document's whole locale + * list, so it repeats — the document and locale are what tell two entries apart. */ export type CollectionStatusItem = { id: string; status: TaskStatus; + collection_id: string; + target_lng: string; }; /** diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts b/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts index 34c2aa1ce..99b28a3b3 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts @@ -4,11 +4,7 @@ import type { TranslationProvider } from "../../../core/domain/translation-provi import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { ConfigModifier } from "../../../types/ConfigModifier.js"; import type { ProvenanceServiceFactory } from "../../modules/provenance/index.js"; -import { - LifecycleNotifier, - taskFromHandlerInput, - withQueuedNotification, -} from "../../modules/lifecycle/index.js"; +import { LifecycleNotifier, taskFromAssignment } from "../../modules/lifecycle/index.js"; import type { TranslationLifecycleCallbacks } from "../../modules/lifecycle/index.js"; import type { TaskRunnerContext, @@ -30,8 +26,8 @@ type WireTranslateRunnerParams = { /** * Assemble the document-translation task pipeline: the {@link TranslateDocumentHandler}, the runner - * context that wraps each task with lifecycle notifications, the runner's config modifier, and the - * per-request {@link TaskRunnerFactory} (decorated with `onQueued` notification when configured). + * context that turns what the runner reports into the host's callbacks, the runner's config + * modifier, and the per-request {@link TaskRunnerFactory}. * * Extracted from the plugin's `init()` so the composition root stays a flat list — `plugin.ts` calls * this once and registers the returned `configModifier` through the shared builder. @@ -57,36 +53,38 @@ export function wireTranslateRunner({ const runnerContext: TaskRunnerContext = { handler: async (payload, input, scope) => { + await translateHandler.handle( + payload, + { + collection: input.collection, + collectionId: input.collectionId, + sourceLng: input.sourceLng, + targetLng: input.targetLng, + strategy: input.strategy, + publishOnTranslation: input.publishOnTranslation, + }, + scope + ); + }, + collections, + report: (payload, assignment, event) => { const notifier = new LifecycleNotifier(lifecycle, payload.logger); - const task = taskFromHandlerInput(input); - try { - await translateHandler.handle( - payload, - { - collection: input.collection, - collectionId: input.collectionId, - sourceLng: input.sourceLng, - targetLng: input.targetLng, - strategy: input.strategy, - publishOnTranslation: input.publishOnTranslation, - }, - scope - ); - } catch (error) { - await notifier.failed(task, error); - throw error; // rethrow so the runner marks the job failed + const task = taskFromAssignment(assignment); + switch (event.state) { + case "queued": + return notifier.queued(task); + case "delivered": + return notifier.completed(task); + case "failed": + return notifier.failed(task, event.error); + case "cancelled": + return notifier.cancelled(task); } - await notifier.completed(task); }, - collections, }; const taskRunnerFactory: TaskRunnerFactory = { - create: (payload) => { - const taskRunner = runner.create(payload, runnerContext.handler); - if (!lifecycle.onQueued) return taskRunner; - return withQueuedNotification(taskRunner, new LifecycleNotifier(lifecycle, payload.logger)); - }, + create: (payload) => runner.create(payload, runnerContext), }; return { taskRunnerFactory, configModifier: runner.configure(runnerContext) }; diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.ts index 3c2bd960d..7b0128c4d 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.ts @@ -43,6 +43,11 @@ export class LifecycleNotifier { await this.safe("lifecycle.onFailed", () => callback(task, error)); } + cancelled(task: TranslationTask): Promise { + const callback = this.callbacks.onCancelled; + return this.safe("lifecycle.onCancelled", callback && (() => callback(task))); + } + private async safe(name: string, thunk?: () => void | Promise): Promise { if (!thunk) return; try { diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/index.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/index.ts index 25494586b..4ebcdb681 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/index.ts @@ -1,6 +1,5 @@ // Translation lifecycle callbacks — always-on host hooks fired around the runner (not gated by the // provenance opt-in, no schema/migration). export { LifecycleNotifier } from "./LifecycleNotifier.js"; -export { withQueuedNotification } from "./withQueuedNotification.js"; -export { taskFromHandlerInput, taskFromInput } from "./taskMapping.js"; +export { taskFromAssignment } from "./taskMapping.js"; export type { TranslationLifecycleCallbacks, TranslationTask } from "./types.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts index 1affeec2c..9c4452129 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts @@ -1,49 +1,26 @@ import { describe, it, expect } from "vitest"; -import type { TaskInput } from "../task-runner/types.js"; -import type { TaskHandlerInput } from "../task-runner/TaskRunnerProvider.interface.js"; -import { taskFromInput, taskFromHandlerInput } from "./taskMapping.js"; -// Both mappers project an internal task shape onto the public `TranslationTask`. The two fragile -// properties are the full field set (incl. `strategy`) and the deliberate omission of -// `publishOnTranslation` (an internal write concern). `toEqual` (exact match) locks both in — a -// leaked field or a dropped `strategy` fails here rather than slipping through `objectContaining`. +import type { EnqueueAssignment } from "../task-runner/types.js"; +import { taskFromAssignment } from "./taskMapping.js"; -describe("taskFromInput", () => { - it("maps every public field and omits publishOnTranslation", () => { - const input: TaskInput = { +describe("taskFromAssignment", () => { + it("maps every field the host is shown, and adds none", () => { + const assignment: EnqueueAssignment = { collectionSlug: "posts", collectionId: "doc-1", sourceLng: "en", targetLng: "de", strategy: "skip_existing", - publishOnTranslation: true, + handle: "run-9", }; - expect(taskFromInput(input)).toEqual({ + + expect(taskFromAssignment(assignment)).toEqual({ collection: "posts", id: "doc-1", sourceLng: "en", targetLng: "de", strategy: "skip_existing", - }); - }); -}); - -describe("taskFromHandlerInput", () => { - it("maps every public field and omits publishOnTranslation", () => { - const input: TaskHandlerInput = { - collection: "pages", - collectionId: "doc-2", - sourceLng: "en", - targetLng: "fr", - strategy: "overwrite", - publishOnTranslation: false, - }; - expect(taskFromHandlerInput(input)).toEqual({ - collection: "pages", - id: "doc-2", - sourceLng: "en", - targetLng: "fr", - strategy: "overwrite", + handle: "run-9", }); }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts index 91990fda0..1c8208fe8 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts @@ -1,21 +1,15 @@ -import type { TaskInput } from "../task-runner/types.js"; -import type { TaskHandlerInput } from "../task-runner/TaskRunnerProvider.interface.js"; +import type { EnqueueAssignment } from "../task-runner/types.js"; import type { TranslationTask } from "./types.js"; -/** Map an enqueue-side {@link TaskInput} to the public {@link TranslationTask}. */ -export const taskFromInput = (task: TaskInput): TranslationTask => ({ - collection: task.collectionSlug, - id: task.collectionId, - sourceLng: task.sourceLng, - targetLng: task.targetLng, - strategy: task.strategy, -}); - -/** Map an execution-side {@link TaskHandlerInput} to the public {@link TranslationTask}. */ -export const taskFromHandlerInput = (input: TaskHandlerInput): TranslationTask => ({ - collection: input.collection, - id: input.collectionId, - sourceLng: input.sourceLng, - targetLng: input.targetLng, - strategy: input.strategy, +/** + * `publishOnTranslation` is deliberately not carried: it is a write concern of the translation, + * not a fact about the task. + */ +export const taskFromAssignment = (assignment: EnqueueAssignment): TranslationTask => ({ + collection: assignment.collectionSlug, + id: assignment.collectionId, + sourceLng: assignment.sourceLng, + targetLng: assignment.targetLng, + strategy: assignment.strategy, + handle: assignment.handle, }); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts index 6c839823b..9544afc3e 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts @@ -17,6 +17,15 @@ export type TranslationTask = { * public type; host callbacks should treat unknown values gracefully. */ strategy: string; + /** + * The run translating this locale, as the enqueue answer named it. Every callback carries it, + * `onQueued` included: the runner names the run before it says it took the work. + * + * Optional because a runner written outside this package may answer an enqueue with nothing. + * + * @since 0.16.0 + */ + handle?: string; }; /** @@ -25,22 +34,37 @@ export type TranslationTask = { * of the `provenance` opt-in. A throwing callback never fails the translation — it is caught and * logged (see {@link LifecycleNotifier}). * - * `onCompleted` / `onFailed` fire per **execution attempt**: the Payload Jobs runner may retry a - * failed task, so a task that fails then succeeds fires `onFailed` on each failed attempt and - * `onCompleted` on the one that succeeds. `onQueued` fires once, when the task is enqueued. + * Each fires once per target locale, never per attempt — a runner's retries are not reported. + * + * @since 0.16.0 `onFailed` reports a final outcome; it previously fired on every failed attempt. * * @since 0.7.0 */ export type TranslationLifecycleCallbacks = { /** - * Fired for each task as it is queued. Best-effort: emitted just before the task is handed to the - * runner, so if enqueueing then throws it may fire for a task that never actually queued. + * Fired once per target locale the runner accepted, before any of the three endings for that + * locale, and before `enqueue` answers. + * + * @since 0.16.0 fires for work the runner has taken, so it no longer fires for a locale that + * failed to queue. * * @since 0.7.0 */ onQueued?: (task: TranslationTask) => void | Promise; /** Fired after a task completes without error. @since 0.7.0 */ onCompleted?: (task: TranslationTask) => void | Promise; - /** Fired when a task's translation throws, with the error. @since 0.7.0 */ + /** + * Fired once when a locale will not be translated, with the failure that ended the run — which for + * a locale that never started is not its own, because nothing ran for it to throw. + * + * @since 0.7.0 + */ onFailed?: (task: TranslationTask, error: unknown) => void | Promise; + /** + * Once per target locale the run still owed, fired before the run's record is deleted, so a host + * can still read it. A locale already in flight is announced and may then still complete. + * + * @since 0.16.0 + */ + onCancelled?: (task: TranslationTask) => void | Promise; }; diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.test.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.test.ts deleted file mode 100644 index 4a88bc7a9..000000000 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import type { CollectionSlug } from "payload"; -import { withQueuedNotification } from "./withQueuedNotification.js"; -import { LifecycleNotifier } from "./LifecycleNotifier.js"; -import type { TaskRunner } from "../task-runner/TaskRunner.interface.js"; -import type { TaskInput } from "../task-runner/types.js"; - -const makeRunner = (): TaskRunner => ({ - enqueue: vi.fn().mockResolvedValue(undefined), - cancel: vi.fn().mockResolvedValue(undefined), - run: vi.fn().mockResolvedValue({ success: true }), - findByCollection: vi.fn().mockResolvedValue([]), -}); - -const input = (id: string): TaskInput => ({ - collectionSlug: "posts" as CollectionSlug, - collectionId: id, - sourceLng: "en", - targetLng: "de", - strategy: "overwrite", - publishOnTranslation: false, -}); - -describe("withQueuedNotification", () => { - it("fires queued for each task (mapped to TranslationTask) then delegates enqueue", async () => { - const runner = makeRunner(); - const onQueued = vi.fn(); - const notifier = new LifecycleNotifier({ onQueued }, { error: vi.fn() }); - - const wrapped = withQueuedNotification(runner, notifier); - await wrapped.enqueue([input("1"), input("2")]); - - expect(onQueued).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ collection: "posts", id: "1", sourceLng: "en", targetLng: "de" }) - ); - expect(onQueued).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: "2" })); - expect(runner.enqueue).toHaveBeenCalledWith([input("1"), input("2")], undefined); - }); - - it("fires queued BEFORE delegating to the runner (ordering vs a synchronous runner)", async () => { - const calls: string[] = []; - const runner = makeRunner(); - (runner.enqueue as ReturnType).mockImplementation(async () => { - calls.push("enqueue"); - }); - const notifier = new LifecycleNotifier( - { - onQueued: () => { - calls.push("queued"); - }, - }, - { error: vi.fn() } - ); - - await withQueuedNotification(runner, notifier).enqueue([input("1")]); - - expect(calls).toEqual(["queued", "enqueue"]); - }); - - it("delegates cancel and run unchanged", async () => { - const runner = makeRunner(); - const wrapped = withQueuedNotification(runner, new LifecycleNotifier({}, { error: vi.fn() })); - - await wrapped.cancel(["a"]); - await wrapped.run("b"); - - expect(runner.cancel).toHaveBeenCalledWith(["a"]); - expect(runner.run).toHaveBeenCalledWith("b"); - }); - - it("hands findByCollection's filter on, normalizing the deprecated array form", async () => { - const runner = makeRunner(); - const wrapped = withQueuedNotification(runner, new LifecycleNotifier({}, { error: vi.fn() })); - - await wrapped.findByCollection("posts" as CollectionSlug, ["c"]); - expect(runner.findByCollection).toHaveBeenCalledWith("posts", { documentIds: ["c"] }); - - await wrapped.findByCollection("posts" as CollectionSlug, { excludeCompleted: true }); - expect(runner.findByCollection).toHaveBeenLastCalledWith("posts", { excludeCompleted: true }); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts deleted file mode 100644 index a52457866..000000000 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { TaskRunner } from "../task-runner/TaskRunner.interface.js"; -import { toTaskFilter } from "../task-runner/toTaskFilter.js"; -import type { LifecycleNotifier } from "./LifecycleNotifier.js"; -import { taskFromInput } from "./taskMapping.js"; - -/** - * Decorate a {@link TaskRunner} so `enqueue` fires the `queued` lifecycle callback for each task. - * Fired BEFORE delegating: a synchronous runner (the sync runner) may execute a task inline during - * `enqueue` and fire `completed`, so emitting `queued` first preserves the queued → completed order. - * All other methods delegate unchanged. - */ -export function withQueuedNotification( - runner: TaskRunner, - notifier: LifecycleNotifier -): TaskRunner { - return { - async enqueue(tasks, scope) { - await Promise.all(tasks.map((task) => notifier.queued(taskFromInput(task)))); - await runner.enqueue(tasks, scope); - }, - cancel: (taskIds) => runner.cancel(taskIds), - run: (taskId) => runner.run(taskId), - // Normalized rather than forwarded as-is: the wrapper's own signature comes from the overload - // pair, so it cannot pass the deprecated array form straight through. - findByCollection: (collectionSlug, filter) => - runner.findByCollection(collectionSlug, toTaskFilter(filter)), - }; -} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunner.interface.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunner.interface.ts index 5e5a32600..4f26872d5 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunner.interface.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunner.interface.ts @@ -1,6 +1,6 @@ import type { CollectionSlug } from "payload"; -import type { Task, TaskInput, RunResult } from "./types.js"; +import type { EnqueueAssignment, Task, TaskInput, RunResult } from "./types.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; /** @@ -10,19 +10,41 @@ import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; * and execution of translation tasks. All business logic * (like cancelling existing tasks before enqueue) is encapsulated * within the implementation. + * + * **Obligations every implementation holds, whatever it queues onto.** `__tests__/TaskRunner.invariants.ts` + * asserts them; a new runner calls that suite once, and the suite's `describe` names are the list. + * + * Two it cannot assert, because they bind the caller rather than the runner: + * - **A handle is never parsed.** Callers compare it, store it and hand it back. + * - **Nothing promises an order.** Callers that need one sort. + * + * Two limits. The obligations reach handles **this runner issued**, and only while the work they + * name exists: SQLite reuses a deleted row's id, so a handle kept across a cancellation can later + * name another run — drop a handle once you are told the work settled. + * + * And a store may reject a value it could never have produced: on SQL the job id is an integer, so + * a non-numeric handle fails inside the query rather than finding nothing. `DELETE /translate/cancel` + * passes such a value straight through and answers with a server error. */ export interface TaskRunner { /** * Queue translation tasks for execution. - * Implementation handles cancellation of existing tasks for the same documents. * * `scope` joins the reads and writes this makes to the caller's transaction; omit it outside one — * an HTTP route — and each operation opens its own. + * + * Answering with nothing still queues and still satisfies this contract — deprecated, + * docs/DEPRECATIONS.md#enqueue-void-return. + * + * @since 0.16.0 the return type widened. */ - enqueue(tasks: TaskInput[], scope?: RequestScope): Promise; + enqueue(tasks: TaskInput[], scope?: RequestScope): Promise; /** - * Cancel tasks by IDs. + * Stop the work these handles stand for, as far as this runner can. + * + * Best effort: a runner that translates inline inside `enqueue` has nothing left to stop. + * Report `cancelled` for each locale the run still owed, and for none it had delivered. */ cancel(taskIds: string[]): Promise; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts index a8c0541ee..cf6a1456e 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts @@ -1,6 +1,6 @@ import type { CollectionSlug, Config, Payload } from "payload"; import type { TaskRunner } from "./TaskRunner.interface.js"; -import type { ID } from "./types.js"; +import type { EnqueueAssignment, ID, TaskEvent } from "./types.js"; import type { TranslationStrategyName } from "../../../core/translation-pipeline/strategies/index.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; @@ -31,6 +31,21 @@ export type TaskHandler = ( export type TaskRunnerContext = { handler: TaskHandler; collections: CollectionSlug[]; + /** + * Tell the plugin what became of one assignment — the only road to the host; a runner that never + * calls this leaves its host hearing nothing. + * + * - `queued` when the work is accepted, then at most one terminal event per assignment. + * - Every `queued` is sent before `enqueue` resolves, every `cancelled` before `cancel` resolves. + * - The assignment carries the handle the enqueue answer gave for that locale. + * - One `queued` per assignment the enqueue answered, **including a locale an existing run already + * covers**; terminal events are counted per assignment identity. + * - `cancelled` only ever follows a `cancel`; a locale already reported `failed` is settled and is + * not owed. + * + * @since 0.16.0 + */ + report: (payload: Payload, assignment: EnqueueAssignment, event: TaskEvent) => Promise; }; /** @@ -64,7 +79,7 @@ export interface TaskRunnerProvider { * `PayloadJobsRunner` ignores it (the handler is baked into the registered * task at configure time); `SyncRunner` uses it to run translations inline. */ - create(payload: Payload, handler: TaskHandler): TaskRunner; + create(payload: Payload, context: TaskRunnerContext): TaskRunner; /** * Configures the runner and returns a Payload config modifier. diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts new file mode 100644 index 000000000..572986bf8 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts @@ -0,0 +1,46 @@ +import type { Config } from "payload"; + +import type { TaskRunnerProvider } from "../../../../index.js"; + +/** + * A compile-only fixture, enforced by `check-types` rather than by a running test. + * + * It imports one name from the published barrel, which is all a third-party runner can reach. + * + * Two mutations must break it: narrowing `enqueue` to require assignments, and dropping `report` + * from `TaskRunnerContext`. Neither obliges a runner to *call* `report` — `silent` below never does + * and compiles, because no type can require that a function be called. + */ + +const configure = () => (config: Config) => config; + +export const silent: TaskRunnerProvider = { + create: () => ({ + enqueue: async () => undefined, + cancel: async (): Promise => undefined, + run: async () => ({ success: false, error: "not_found" }) as const, + findByCollection: async () => [], + }), + configure, +}; + +export const answering: TaskRunnerProvider = { + create: (payload, context) => ({ + enqueue: async (tasks) => { + const assigned = tasks.map((task) => ({ + collectionSlug: task.collectionSlug, + collectionId: task.collectionId, + sourceLng: task.sourceLng, + targetLng: task.targetLng, + strategy: task.strategy, + handle: "run-1", + })); + for (const a of assigned) await context.report(payload, a, { state: "queued" }); + return assigned; + }, + cancel: async (): Promise => undefined, + run: async () => ({ success: false, error: "not_found" }) as const, + findByCollection: async () => [], + }), + configure, +}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts new file mode 100644 index 000000000..bc639ef4f --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts @@ -0,0 +1,487 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { TaskRunner } from "../TaskRunner.interface.js"; +import type { TaskRunnerContext } from "../TaskRunnerProvider.interface.js"; +import type { EnqueueAssignment, TaskEvent, TaskInput } from "../types.js"; + +const SLUG = "posts"; + +const input = (collectionId: string, targetLng: string): TaskInput => ({ + collectionSlug: SLUG, + collectionId, + sourceLng: "en", + targetLng, + strategy: "overwrite", + publishOnTranslation: false, +}); + +type Addressed = { collectionSlug: string; collectionId: string; targetLng: string }; + +const addressOf = (target: Addressed): string => + `${target.collectionSlug}/${target.collectionId}/${target.targetLng}`; + +const assignmentKey = (assignment: EnqueueAssignment): string => + `${assignment.handle}@${addressOf(assignment)}`; + +type ReportState = TaskEvent["state"]; + +const isTerminal = (event: TaskEvent): boolean => event.state !== "queued"; + +const cancelledAndStillDelivered = (states: ReportState[]): boolean => + states.length === 2 && states.includes("cancelled") && states.includes("delivered"); + +const settle = (promise: Promise): Promise<"resolved" | "rejected"> => + promise.then(() => "resolved" as const).catch(() => "rejected" as const); + +type Reported = { assignment: EnqueueAssignment; event: TaskEvent }; + +const handlesOf = (answer: EnqueueAssignment[]): string[] => [ + ...new Set(answer.map((assignment) => assignment.handle)), +]; + +export type MakeTaskRunner = ( + report: TaskRunnerContext["report"] +) => TaskRunner | Promise; + +/** + * The obligations `TaskRunner` owes its callers, asserted against one implementation. + * + * Every check traces to a sentence of `TaskRunner.interface.ts`, `TaskRunnerProvider.interface.ts` + * or `types.ts`; the nested `describe` names quote the obligation they belong to. Checks that need + * a handle from a runner that answers `enqueue` with nothing skip themselves instead of failing. + * + * Nothing here waits for work to finish: a runner whose terminal events land after the call under + * test resolves passes these checks vacuously. + * + * @param name - how the implementation under test is reported + * @param make - called fresh for every check + */ +export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): void { + describe(name, () => { + let runner: TaskRunner; + let reported: Reported[] = []; + // A real database keeps what earlier checks queued. + let checkNumber = 0; + const documentForThisCheck = (n: number): string => `doc-${checkNumber}-${n}`; + + const record: TaskRunnerContext["report"] = (_payload, assignment, event) => { + reported.push({ assignment, event }); + return Promise.resolve(); + }; + + beforeEach(async () => { + checkNumber += 1; + reported = []; + runner = await make(record); + }); + + const enqueued = async (tasks: TaskInput[]): Promise => { + const answer = await runner.enqueue(tasks); + return Array.isArray(answer) ? answer : undefined; + }; + + const twoLocales = (): TaskInput[] => [ + input(documentForThisCheck(1), "de"), + input(documentForThisCheck(1), "fr"), + ]; + + const threeLocales = (): TaskInput[] => [ + input(documentForThisCheck(1), "de"), + input(documentForThisCheck(1), "fr"), + input(documentForThisCheck(2), "de"), + ]; + + const addressesReported = (state: ReportState): string[] => + reported + .filter((entry) => entry.event.state === state) + .map((entry) => addressOf(entry.assignment)); + + const timesQueued = (address: string): number => + addressesReported("queued").filter((queued) => queued === address).length; + + const wasReported = (assignment: EnqueueAssignment, state: ReportState): boolean => + reported.some( + (entry) => + entry.event.state === state && + assignmentKey(entry.assignment) === assignmentKey(assignment) + ); + + const terminalsPerAssignment = (): Array<[string, ReportState[]]> => { + const collected = new Map(); + for (const { assignment, event } of reported) { + if (!isTerminal(event)) continue; + const key = assignmentKey(assignment); + collected.set(key, [...(collected.get(key) ?? []), event.state]); + } + return [...collected]; + }; + + describe("an empty request is answered, not refused", () => { + it("answers an empty enqueue", async () => { + const answer: unknown = await runner.enqueue([]); + + expect(answer ?? [], "enqueue([]) answers [] or nothing").toEqual([]); + }); + + it("answers an empty cancel", async () => { + const answer: unknown = await runner.cancel([]); + + expect(answer ?? [], "cancel([]) answers [] or undefined").toEqual([]); + }); + + it("reports nothing about an empty enqueue", async () => { + await runner.enqueue([]); + + expect(reported, "an empty request accepts no assignment to report").toEqual([]); + }); + + it("reports nothing about an empty cancel", async () => { + await runner.cancel([]); + + expect(reported, "no handle names work to stop, so nothing settled").toEqual([]); + }); + + it("answers about no documents, not about all of them", async () => { + expect(await runner.findByCollection(SLUG, [])).toEqual([]); + }); + }); + + describe("a handle is a non-empty string", () => { + it("names each assignment with a non-empty handle", async (ctx) => { + const answer = await enqueued(twoLocales()); + if (!answer) { + ctx.skip(); + return; + } + + const unusable = answer.filter((assignment) => assignment.handle.length === 0); + + expect(unusable, "a handle callers can store and hand back").toEqual([]); + }); + + it("takes its own handles back", async (ctx) => { + const answer = await enqueued([input(documentForThisCheck(1), "de")]); + if (!answer) { + ctx.skip(); + return; + } + + expect( + await settle(runner.cancel(handlesOf(answer))), + "cancel takes the runner's own handle back unchanged" + ).toBe("resolved"); + }); + }); + + describe("only what was asked for is answered", () => { + it("answers only locales the request listed", async (ctx) => { + const request = threeLocales(); + const answer = await enqueued(request); + if (!answer) { + ctx.skip(); + return; + } + const asked = new Set(request.map(addressOf)); + + const unasked = answer.map(addressOf).filter((address) => !asked.has(address)); + + expect(unasked, "every assignment names a locale the request listed").toEqual([]); + }); + + it("answers a locale at most once per document", async (ctx) => { + const answer = await enqueued(threeLocales()); + if (!answer) { + ctx.skip(); + return; + } + const addresses = answer.map(addressOf); + + expect(new Set(addresses).size, "no locale is announced twice for one document").toBe( + addresses.length + ); + }); + + it("answers once although the request listed a locale twice", async (ctx) => { + const twice = [input(documentForThisCheck(1), "de"), input(documentForThisCheck(1), "de")]; + const answer = await enqueued(twice); + if (!answer) { + ctx.skip(); + return; + } + const address = addressOf(twice[0]); + + const found = answer.filter((assignment) => addressOf(assignment) === address); + + expect(found.length, "a locale appears at most once per document").toBeLessThanOrEqual(1); + }); + + it("answers an assignment per requested locale", async (ctx) => { + const request = threeLocales(); + const answer = await enqueued(request); + if (!answer) { + ctx.skip(); + return; + } + const assigned = new Set(answer.map(addressOf)); + + const missing = [...new Set(request.map(addressOf))].filter( + (address) => !assigned.has(address) + ); + + expect(missing, "an EnqueueAssignment per requested target locale").toEqual([]); + }); + + it("answers a locale an existing run already covers", async (ctx) => { + const covered = input(documentForThisCheck(1), "de"); + const first = await enqueued([covered, input(documentForThisCheck(1), "fr")]); + const again = first ? await enqueued([covered]) : undefined; + if (!again) { + ctx.skip(); + return; + } + + expect( + again.map(addressOf), + "a locale an existing run covers is owed that run's handle" + ).toContain(addressOf(covered)); + }); + }); + + describe("what became of every accepted assignment reaches the plugin", () => { + it("reports queued for every requested locale", async () => { + const request = twoLocales(); + + await runner.enqueue(request); + + const silent = [...new Set(request.map(addressOf))].filter( + (address) => timesQueued(address) === 0 + ); + expect(silent, "queued is sent for every locale the runner accepted").toEqual([]); + }); + + it("reports queued at most once per requested locale", async () => { + const request = twoLocales(); + + await runner.enqueue(request); + + const repeated = [...new Set(request.map(addressOf))].filter( + (address) => timesQueued(address) > 1 + ); + expect(repeated, "one queued per requested locale, not several").toEqual([]); + }); + + it("reports queued once although the request listed a locale twice", async () => { + const twice = [input(documentForThisCheck(1), "de"), input(documentForThisCheck(1), "de")]; + + await runner.enqueue(twice); + + expect( + timesQueued(addressOf(twice[0])), + "a locale listed twice is one piece of accepted work" + ).toBeLessThanOrEqual(1); + }); + + it("reports only about locales the request listed", async () => { + const request = twoLocales(); + const asked = new Set(request.map(addressOf)); + + await runner.enqueue(request); + + const unasked = reported + .map((entry) => addressOf(entry.assignment)) + .filter((address) => !asked.has(address)); + expect(unasked, "every report names a locale the request listed").toEqual([]); + }); + + it("reports no terminal event for a locale before its queued", async () => { + await runner.enqueue(twoLocales()); + + const premature = reported + .filter( + (entry, index) => + isTerminal(entry.event) && + !reported + .slice(0, index) + .some( + (earlier) => + earlier.event.state === "queued" && + addressOf(earlier.assignment) === addressOf(entry.assignment) + ) + ) + .map((entry) => `${addressOf(entry.assignment)}:${entry.event.state}`); + + expect(premature, "queued comes before any terminal event for that locale").toEqual([]); + }); + + it("reports at most one terminal event per assignment", async () => { + await runner.enqueue(twoLocales()); + + const several = terminalsPerAssignment().filter(([, states]) => states.length > 1); + + expect(several, "one assignment settles once").toEqual([]); + }); + }); + + describe("cancelling reports what the run still owed", () => { + it("reports cancelled for every locale the run still owed", async (ctx) => { + const answer = await enqueued(twoLocales()); + if (!answer) { + ctx.skip(); + return; + } + const owed = answer.filter( + (assignment) => + !reported.some( + (entry) => + isTerminal(entry.event) && + assignmentKey(entry.assignment) === assignmentKey(assignment) + ) + ); + + await runner.cancel(handlesOf(answer)); + + const unheard = owed.filter((assignment) => !wasReported(assignment, "cancelled")); + expect(unheard.map(addressOf), "cancelled for each locale the run still owed").toEqual([]); + }); + + it("reports cancelled for no locale the run had delivered", async (ctx) => { + const answer = await enqueued(twoLocales()); + if (!answer) { + ctx.skip(); + return; + } + const delivered = answer.filter((assignment) => wasReported(assignment, "delivered")); + + await runner.cancel(handlesOf(answer)); + + const revoked = delivered.filter((assignment) => wasReported(assignment, "cancelled")); + expect(revoked.map(addressOf), "a delivered locale is not reported cancelled").toEqual([]); + }); + + it("reports at most one terminal event per cancelled assignment", async (ctx) => { + const answer = await enqueued(twoLocales()); + if (!answer) { + ctx.skip(); + return; + } + + await runner.cancel(handlesOf(answer)); + + const several = terminalsPerAssignment().filter( + ([, states]) => states.length > 1 && !cancelledAndStillDelivered(states) + ); + expect( + several, + "one assignment settles once, bar a locale already executing when cancelled" + ).toEqual([]); + }); + + it("reports nothing cancelled about a run whose handle it was not given", async (ctx) => { + const spared = await enqueued([input(documentForThisCheck(1), "de")]); + const cancelled = await enqueued([input(documentForThisCheck(2), "de")]); + if (!spared || !cancelled) { + ctx.skip(); + return; + } + const given = new Set(handlesOf(cancelled)); + const untouched = new Set(spared.filter((a) => !given.has(a.handle)).map(assignmentKey)); + + await runner.cancel([...given]); + + const strays = reported.filter( + (entry) => + entry.event.state === "cancelled" && untouched.has(assignmentKey(entry.assignment)) + ); + expect( + strays.map((entry) => addressOf(entry.assignment)), + "cancel stops the work these handles stand for, not another run's" + ).toEqual([]); + }); + }); + + describe("a handle that no longer names anything is an answer, not a failure", () => { + /** + * A handle this runner issued and then had cancelled. Unknown now, and shaped the way this + * runner shapes handles — which is as far as the obligations reach: a store may reject a + * value it could never have produced, and on SQL a non-numeric handle does exactly that. + */ + const spent = async (): Promise => { + const answer = await enqueued([input(documentForThisCheck(9), "de")]); + const handle = answer?.[0]?.handle; + if (handle) await runner.cancel([handle]); + return handle; + }; + + it("answers run on a spent handle with not_found", async (ctx) => { + const handle = await spent(); + if (!handle) { + ctx.skip(); + return; + } + + expect(await runner.run(handle)).toEqual({ success: false, error: "not_found" }); + }); + + it("resolves cancel on a spent handle", async (ctx) => { + const handle = await spent(); + if (!handle) { + ctx.skip(); + return; + } + + expect( + await settle(runner.cancel([handle])), + "cancel on a spent handle resolves rather than rejecting" + ).toBe("resolved"); + }); + }); + + describe("findByCollection finds tasks for a collection", () => { + it("answers only tasks of the collection asked about", async () => { + await runner.enqueue([input(documentForThisCheck(1), "de")]); + + const tasks = await runner.findByCollection(SLUG); + + expect( + tasks.filter((task) => task.input.collectionSlug !== SLUG), + "every task belongs to the collection asked about" + ).toEqual([]); + }); + + it("keeps only the documents the filter names", async () => { + const kept = documentForThisCheck(1); + await runner.enqueue([input(kept, "de"), input(documentForThisCheck(2), "de")]); + + const tasks = await runner.findByCollection(SLUG, { documentIds: [kept] }); + + expect( + tasks.filter((task) => task.input.collectionId !== kept), + "documentIds keeps only tasks for these documents" + ).toEqual([]); + }); + + it("keeps only the documents the deprecated array names", async () => { + const kept = documentForThisCheck(1); + await runner.enqueue([input(kept, "de"), input(documentForThisCheck(2), "de")]); + + const tasks = await runner.findByCollection(SLUG, [kept]); + + expect( + tasks.filter((task) => task.input.collectionId !== kept), + "the array means what { documentIds } means" + ).toEqual([]); + }); + + it("drops tasks that have finished", async () => { + await runner.enqueue([input(documentForThisCheck(1), "de")]); + + const tasks = await runner.findByCollection(SLUG, { excludeCompleted: true }); + + expect( + tasks.filter((task) => task.status === "completed"), + "excludeCompleted drops tasks that have finished" + ).toEqual([]); + }); + }); + }); +} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/index.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/index.ts index 7863bc2cf..6a2021075 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/index.ts @@ -3,7 +3,7 @@ export type { TaskRunnerFactory, TaskRunnerContext, } from "./TaskRunnerProvider.interface.js"; -export type { Task, TaskStatus } from "./types.js"; +export type { EnqueueAssignment, Task, TaskStatus } from "./types.js"; export { createPayloadJobsRunner } from "./payload-jobs-runner/index.js"; export type { PayloadJobsRunnerOptions } from "./payload-jobs-runner/index.js"; export { createSyncRunner } from "./sync-runner/index.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts index 8cd5fbd69..7cb49e7da 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts @@ -3,14 +3,20 @@ import type { Config, Field, Payload, WorkflowConfig } from "payload"; import type { TaskRunner } from "../TaskRunner.interface.js"; import type { + PayloadJob, PayloadJobsRunnerOptions, PayloadJobsRunnerConfig, AutoRunConfig, StoredWorkflowInput, } from "./types.js"; import { PayloadJobsTaskRunner } from "./PayloadJobsTaskRunner.js"; +import { assignmentOf } from "./assignmentOf.js"; import { readCollectionRef } from "./readCollectionRef.js"; +import { latestLogByLocale } from "./normalizeJob.js"; +import { owedIfGaveUp } from "./owedIfGaveUp.js"; +import { retryLimitOf } from "./retryLimitOf.js"; import type { TaskRunnerContext, TaskRunnerProvider } from "../TaskRunnerProvider.interface.js"; +import type { TaskEvent } from "../types.js"; import type { Requester } from "../../../shared/payload/RequestScope.shapes.js"; import { asRequester } from "../../../shared/payload/RequestScope.shapes.js"; import type { TranslationStrategyName } from "../../../../core/translation-pipeline/strategies/index.js"; @@ -80,13 +86,23 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { }; } - create(payload: Payload): TaskRunner { - return new PayloadJobsTaskRunner(payload, this.config); + create(payload: Payload, context: TaskRunnerContext): TaskRunner { + return new PayloadJobsTaskRunner(payload, this.config, context); } configure(context: TaskRunnerContext): (config: Config) => Config { const { taskName, workflowName, queueName, retries, autoRun } = this.config; - const { handler, collections } = context; + const retryLimit = retryLimitOf(retries); + const { handler, collections, report } = context; + /** + * What the plugin's own handler threw, kept until the run's loop can report it. + * + * Payload reads `message` off a task's error and throws a `TaskError` of its own + * (`getRunTaskFunction.js:83`), so the plugin's error — and the exported error classes a host + * matches on — is gone by the time the loop catches anything. Keyed by the job row, which + * Payload passes by reference to both handlers registered below. + */ + const thrown = new WeakMap(); return (config) => { const inputSchema: Field[] = [ @@ -148,6 +164,7 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { retries, handler: async (args: { req: { payload: Payload }; + job?: { id?: unknown }; input: { collection_slug?: string; collection_id?: string; @@ -161,18 +178,23 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { }; }) => { const { collectionSlug, collectionId } = readCollectionRef(args.input); - await handler( - args.req.payload, - { - collection: collectionSlug, - collectionId, - sourceLng: args.input.source_lng, - targetLng: args.input.target_lng, - strategy: args.input.strategy, - publishOnTranslation: args.input.publish_on_translation ?? false, - }, - { requester: requesterOf(args.input) } - ); + try { + await handler( + args.req.payload, + { + collection: collectionSlug, + collectionId, + sourceLng: args.input.source_lng, + targetLng: args.input.target_lng, + strategy: args.input.strategy, + publishOnTranslation: args.input.publish_on_translation ?? false, + }, + { requester: requesterOf(args.input) } + ); + } catch (error) { + if (args.job) thrown.set(args.job, error); + throw error; + } return { output: {} }; }, }; @@ -189,13 +211,33 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { }, } : {}), - handler: async ({ job, tasks }) => { + handler: async ({ job, req, tasks }) => { const runLocale = (tasks as Record)[taskName]; + const storedJob = job as unknown as PayloadJob; + const reportFor = async (locale: string, event: TaskEvent) => { + const assignment = assignmentOf(storedJob, locale); + if (assignment) await report(req.payload, assignment, event); + }; for (let i = 0; ; i++) { const { target_lngs: targets, ...shared } = job.input; const target = targets?.[i]; if (target === undefined) return; - await runLocale(target, { input: { ...shared, target_lng: target } }); + // Payload hands back a completed task's stored output instead of re-running it, so a + // later pass through this loop would report `delivered` twice — read the log first. + const deliveredEarlier = + latestLogByLocale(storedJob).get(target)?.state === "succeeded"; + try { + await runLocale(target, { input: { ...shared, target_lng: target } }); + if (!deliveredEarlier) await reportFor(target, { state: "delivered" }); + } catch (error) { + // A cancellation deletes the run's row under it, so Payload's own machinery throws — + // that is not a locale giving up. + const owed = thrown.has(job) ? owedIfGaveUp(job, taskName, target, retryLimit) : []; + for (const dead of owed) { + await reportFor(dead.input.targetLng, { state: "failed", error: thrown.get(job) }); + } + throw error; + } } }, }; @@ -230,7 +272,7 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { config.onInit = async (payload) => { if (existingOnInit) await existingOnInit(payload); try { - await new PayloadJobsTaskRunner(payload, this.config).reclaimStaleJobs(); + await new PayloadJobsTaskRunner(payload, this.config, context).reclaimStaleJobs(); } catch (err) { payload.logger?.error?.({ err, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts index 25ca60a21..e857671ce 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts @@ -1,10 +1,14 @@ import type { Payload, Where, CollectionSlug } from "payload"; import type { TaskFilter, TaskRunner } from "../TaskRunner.interface.js"; +import type { TaskRunnerContext } from "../TaskRunnerProvider.interface.js"; import { toTaskFilter } from "../toTaskFilter.js"; -import type { Task, TaskInput, RunResult } from "../types.js"; +import type { EnqueueAssignment, Task, TaskInput, RunResult } from "../types.js"; import type { PayloadJobsRunnerConfig, PayloadJob, StoredWorkflowInput } from "./types.js"; +import { assignmentOf } from "./assignmentOf.js"; +import { handleOf } from "./handleOf.js"; import { normalizeJobLocales } from "./normalizeJob.js"; +import { owedOnCancel } from "./owedOnCancel.js"; import { planEnqueue } from "./planEnqueue.js"; import type { RequestShape } from "./planEnqueue.js"; import { readCollectionRef } from "./readCollectionRef.js"; @@ -20,7 +24,17 @@ type QueueWorkflow = (args: { waitUntil?: Date; input: StoredWorkflowInput; req?: { transactionID?: string | number }; -}) => Promise; +}) => Promise<{ id?: unknown } | undefined>; + +const assign = (request: RequestShape, targetLngs: string[], handle: string): EnqueueAssignment[] => + targetLngs.map((targetLng) => ({ + collectionSlug: request.collectionSlug as CollectionSlug, + collectionId: request.collectionId, + sourceLng: request.sourceLng, + targetLng, + strategy: request.strategy, + handle, + })); function requestShape(task: TaskInput, scope: RequestScope): RequestShape { return { @@ -43,12 +57,17 @@ function documentKey(collectionSlug: string, collectionId: string): string { } export class PayloadJobsTaskRunner implements TaskRunner { - constructor( - private readonly payload: Payload, - private readonly config: PayloadJobsRunnerConfig - ) {} + private readonly payload: Payload; + private readonly config: PayloadJobsRunnerConfig; + private readonly context: TaskRunnerContext; + + constructor(payload: Payload, config: PayloadJobsRunnerConfig, context: TaskRunnerContext) { + this.payload = payload; + this.config = config; + this.context = context; + } - async enqueue(tasks: TaskInput[], scope: RequestScope = {}): Promise { + async enqueue(tasks: TaskInput[], scope: RequestScope = {}): Promise { const byRequest = new Map(); for (const task of tasks) { const key = requestKey(task, scope); @@ -67,13 +86,19 @@ export class PayloadJobsTaskRunner implements TaskRunner { const exclusiveQueue = Boolean(this.payload.config.jobs?.enableConcurrencyControl); const groups = [...byRequest.values()]; + const assigned: EnqueueAssignment[] = []; for (let i = 0; i < groups.length; i += ENQUEUE_CONCURRENCY) { - await Promise.all( + const served = await Promise.all( groups .slice(i, i + ENQUEUE_CONCURRENCY) .map((group) => this.serve(group, liveByDocument, exclusiveQueue, scope)) ); + for (const entries of served) assigned.push(...entries); + } + for (const assignment of assigned) { + await this.context.report(this.payload, assignment, { state: "queued" }); } + return assigned; } private async serve( @@ -81,7 +106,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { liveByDocument: Map, exclusiveQueue: boolean, scope: RequestScope - ): Promise { + ): Promise { const [first] = group; const request = requestShape(first, scope); const plan = planEnqueue({ @@ -95,7 +120,18 @@ export class PayloadJobsTaskRunner implements TaskRunner { ? await this.extendJob(plan.host, plan.append, first.waitUntil, scope) : []; const queue = [...plan.queue, ...undelivered]; - if (queue.length > 0) await this.queueWorkflow(request, queue, first.waitUntil, scope); + const delivered = plan.append.filter((locale) => !undelivered.includes(locale)); + + const onTheHost = plan.host ? assign(request, delivered, String(plan.host.id)) : []; + const onTheCovering = plan.coveredBy + ? assign(request, plan.covered, String(plan.coveredBy.id)) + : []; + if (queue.length === 0) return [...onTheHost, ...onTheCovering]; + + const handle = await this.queueWorkflow(request, queue, first.waitUntil, scope); + return handle === null + ? [...onTheHost, ...onTheCovering] + : [...onTheHost, ...onTheCovering, ...assign(request, queue, handle)]; } private async extendJob( @@ -123,7 +159,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { returning: false, }); - const reread = await this.findJobById(job.id, scope); + const reread = await this.findJobById(String(job.id), scope); if (!reread || reread.completedAt) return locales; current = reread; @@ -139,7 +175,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { targetLngs: string[], waitUntil: Date | undefined, scope: RequestScope - ): Promise { + ): Promise { const input: StoredWorkflowInput = { collection_slug: request.collectionSlug, collection_id: request.collectionId, @@ -152,18 +188,30 @@ export class PayloadJobsTaskRunner implements TaskRunner { }; const queueJob = this.payload.jobs.queue as unknown as QueueWorkflow; - await queueJob({ - workflow: this.config.workflowName, - queue: this.config.queueName, - waitUntil, - input, - req: freshReq(scope), - }); + return handleOf( + await queueJob({ + workflow: this.config.workflowName, + queue: this.config.queueName, + waitUntil, + input, + req: freshReq(scope), + }) + ); } async cancel(taskIds: string[]): Promise { if (taskIds.length === 0) return; + // Read before writing: once the rows are deleted there is nothing left to say what was owed. + for (const job of await this.findRawJobs({ id: { in: taskIds } })) { + for (const task of owedOnCancel(job)) { + const assignment = assignmentOf(job, task.input.targetLng); + if (assignment) { + await this.context.report(this.payload, assignment, { state: "cancelled" }); + } + } + } + await this.payload.jobs.cancel({ where: { and: [this.ownJobs(), { id: { in: taskIds } }] }, queue: this.config.queueName, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsRunnerProvider.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsRunnerProvider.test.ts index 36651752c..e28ddf060 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsRunnerProvider.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsRunnerProvider.test.ts @@ -5,6 +5,7 @@ import type { TaskRunnerContext } from "../../TaskRunnerProvider.interface.js"; const minimalContext: TaskRunnerContext = { handler: vi.fn(), + report: vi.fn().mockResolvedValue(undefined), collections: ["pages"], }; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.cancel.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.cancel.test.ts index 43a69b934..7576e90cf 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.cancel.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.cancel.test.ts @@ -4,6 +4,12 @@ import type { Payload } from "payload"; import { PayloadJobsTaskRunner } from "../PayloadJobsTaskRunner.js"; import type { PayloadJobsRunnerConfig } from "../types.js"; +const testContext = { + handler: vi.fn().mockResolvedValue(undefined), + collections: [], + report: vi.fn().mockResolvedValue(undefined), +}; + describe("PayloadJobsTaskRunner — cancel reaches only the plugin's own jobs", () => { let payload: Payload & { jobs: { cancel: ReturnType } }; let runner: PayloadJobsTaskRunner; @@ -23,7 +29,7 @@ describe("PayloadJobsTaskRunner — cancel reaches only the plugin's own jobs", staleJobTimeoutMs: 60_000, } as PayloadJobsRunnerConfig; - runner = new PayloadJobsTaskRunner(payload, config); + runner = new PayloadJobsTaskRunner(payload, config, testContext); }); // The delete that follows is already scoped by `ownJobs()`. The cancel is not — it narrows by id diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts index 7e3439d09..fa82fd85c 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts @@ -5,6 +5,12 @@ import type { PayloadJobsRunnerConfig, PayloadJob } from "../types.js"; import type { TaskInput } from "../../types.js"; import { asRequester } from "../../../../shared/payload/RequestScope.shapes.js"; +const testContext = { + handler: vi.fn().mockResolvedValue(undefined), + collections: [], + report: vi.fn().mockResolvedValue(undefined), +}; + describe("PayloadJobsTaskRunner", () => { let mockPayload: { find: ReturnType; @@ -51,7 +57,7 @@ describe("PayloadJobsTaskRunner", () => { }, staleJobTimeoutMs: 300_000, }; - runner = new PayloadJobsTaskRunner(mockPayload as unknown as Payload, config); + runner = new PayloadJobsTaskRunner(mockPayload as unknown as Payload, config, testContext); }); const createInput = (overrides: Partial = {}): TaskInput => ({ diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts new file mode 100644 index 000000000..2288f83d1 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { alreadyCovered } from "../alreadyCovered.js"; +import type { JobLogEntry, PayloadJob } from "../types.js"; + +const translated = (targetLng: string, completedAt = "2024-01-01T00:01:00Z"): JobLogEntry => ({ + state: "succeeded", + completedAt, + input: { target_lng: targetLng }, +}); + +const attemptFailed = (targetLng: string, completedAt = "2024-01-01T00:01:00Z"): JobLogEntry => ({ + state: "failed", + completedAt, + input: { target_lng: targetLng }, +}); + +const runCarrying = (targetLngs: string[], log?: JobLogEntry[]): PayloadJob => ({ + id: "job-live", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + processing: true, + log, + input: { + collection_slug: "posts", + collection_id: "doc-1", + source_lng: "en", + target_lngs: targetLngs, + strategy: "overwrite", + }, +}); + +describe("alreadyCovered", () => { + describe("a locale the run lists and has not translated", () => { + it("covers it when it has not been attempted, while another locale of the same run is translated", () => { + const run = runCarrying(["de", "fr"], [translated("de")]); + expect(alreadyCovered(run, ["fr"])).toEqual(["fr"]); + }); + + it("covers it when its attempt failed, because the run will come back to it", () => { + const run = runCarrying(["de", "fr"], [translated("de"), attemptFailed("fr")]); + expect(alreadyCovered(run, ["fr"])).toEqual(["fr"]); + }); + }); + + describe("a locale the run lists and has translated", () => { + it("does not cover it, while the run still has an unreached locale", () => { + const run = runCarrying(["de", "fr"], [translated("de")]); + expect(alreadyCovered(run, ["de"])).toEqual([]); + }); + }); + + describe("several records for one locale", () => { + it("does not cover a locale that failed and was then translated", () => { + const run = runCarrying( + ["de", "fr"], + [attemptFailed("de", "2024-01-01T00:01:00Z"), translated("de", "2024-01-01T00:03:00Z")] + ); + expect(alreadyCovered(run, ["de"])).toEqual([]); + }); + + it("does not cover a locale that was translated and has a later failure", () => { + const run = runCarrying( + ["de", "fr"], + [translated("de", "2024-01-01T00:01:00Z"), attemptFailed("de", "2024-01-01T00:03:00Z")] + ); + expect(alreadyCovered(run, ["de"])).toEqual([]); + }); + }); + + describe("a record that names no locale", () => { + it("ignores it, so the locale it cannot identify stays covered", () => { + const run = runCarrying( + ["de", "fr"], + [ + { state: "succeeded", completedAt: "2024-01-01T00:01:00Z" }, + { state: "succeeded", completedAt: "2024-01-01T00:02:00Z", input: {} }, + ] + ); + expect(alreadyCovered(run, ["de"])).toEqual(["de"]); + }); + }); + + describe("a locale the run does not list", () => { + it("does not cover it, though the run has locales it has not reached", () => { + const run = runCarrying(["de", "fr"]); + expect(alreadyCovered(run, ["it"])).toEqual([]); + }); + }); + + describe("a locale asked for twice", () => { + it("answers it once", () => { + const run = runCarrying(["de", "fr"]); + expect(alreadyCovered(run, ["de", "de"])).toEqual(["de"]); + }); + }); + + describe("locales the request did not ask for", () => { + it("leaves out a locale the run carries and has not reached but nobody requested", () => { + const run = runCarrying(["de", "fr"]); + expect(alreadyCovered(run, ["de"])).toEqual(["de"]); + }); + }); + + describe("order", () => { + it("follows the request, not the order the run lists its locales in", () => { + const run = runCarrying(["de", "fr", "es"]); + expect(alreadyCovered(run, ["es", "de", "fr"])).toEqual(["es", "de", "fr"]); + }); + }); + + describe("empty request", () => { + it("yields nothing, though the run carries locales it has not reached", () => { + const run = runCarrying(["de", "fr"]); + expect(alreadyCovered(run, [])).toEqual([]); + }); + }); + + describe("a run carrying no locales", () => { + it("yields nothing", () => { + const run = runCarrying([]); + expect(alreadyCovered(run, ["de", "fr"])).toEqual([]); + }); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/handleOf.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/handleOf.test.ts new file mode 100644 index 000000000..1ebac5bee --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/handleOf.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { handleOf } from "../handleOf.js"; + +describe("handleOf", () => { + it("passes a textual id through", () => { + expect(handleOf({ id: "6704f1c0a1b2c3d4e5f60718" })).toBe("6704f1c0a1b2c3d4e5f60718"); + }); + + it("spells a numeric id as a string, so both databases answer the same way", () => { + expect(handleOf({ id: 42 })).toBe("42"); + }); + + it.each([ + ["a row with no id", {}], + ["nothing at all", undefined], + ["an id of a kind no database hands back", { id: { oid: "x" } }], + ])("has no handle for %s", (_label, candidate) => { + expect(handleOf(candidate)).toBeNull(); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/isLastAttempt.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/isLastAttempt.test.ts new file mode 100644 index 000000000..3225f35a5 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/isLastAttempt.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { isLastAttempt } from "../isLastAttempt.js"; + +describe("isLastAttempt", () => { + it.each([ + [0, 3, false], + [1, 3, false], + [2, 3, false], + [3, 3, true], + [4, 3, true], + ])("with %i attempts behind it and a limit of %i, says %s", (before, limit, expected) => { + expect(isLastAttempt({ totalTried: before }, limit)).toBe(expected); + }); + + it("treats a locale with no record as being on its first attempt", () => { + expect( + isLastAttempt(undefined, 1), + "zero attempts behind it, limit one — one more to come" + ).toBe(false); + }); + + it("treats a record that does not count as no attempts", () => { + expect(isLastAttempt({}, 1)).toBe(false); + }); + + it("says yes on the first failure when the run was told not to retry", () => { + expect(isLastAttempt(undefined, 0), "a limit of zero leaves nothing to come").toBe(true); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts new file mode 100644 index 000000000..e59b8eb75 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { PayloadJob } from "../types.js"; +import { owedIfGaveUp } from "../owedIfGaveUp.js"; + +const TASK = "translate-locale"; + +const run = (parts: Partial = {}): PayloadJob => + ({ + id: 7, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + input: { + collection_slug: "docs", + collection_id: "doc-1", + source_lng: "en", + target_lngs: ["de", "fr", "es"], + strategy: "overwrite", + }, + ...parts, + }) as PayloadJob; + +const tried = (locale: string, totalTried: number) => ({ [TASK]: { [locale]: { totalTried } } }); + +const locales = (job: PayloadJob, target: string, limit: number) => + owedIfGaveUp(job, TASK, target, limit).map((task) => task.input.targetLng); + +describe("owedIfGaveUp", () => { + it("says nothing while the run still has an attempt left for the locale", () => { + expect(locales(run({ taskStatus: tried("de", 1) }), "de", 3)).toEqual([]); + }); + + it("names every locale the run never reached, not just the one that threw", () => { + expect(locales(run({ taskStatus: tried("de", 3) }), "de", 3)).toEqual(["de", "fr", "es"]); + }); + + it("leaves out a locale the run had already translated", () => { + const job = run({ + taskStatus: tried("fr", 3), + log: [{ state: "succeeded", input: { target_lng: "de" } }], + } as Partial); + + expect(locales(job, "fr", 3)).toEqual(["fr", "es"]); + }); + + it("counts a locale on its first try as nothing tried yet", () => { + expect( + locales(run(), "de", 0), + "no record means zero attempts before this one, which with no retries is already the last" + ).toEqual(["de", "fr", "es"]); + }); + + it("reports when the run's own attempts are spent, although this locale has some left", () => { + const job = run({ totalTried: 3, taskStatus: tried("de", 1) }); + expect( + locales(job, "de", 3), + "Payload stops a run on either counter, so a locale with budget left still never runs" + ).toEqual(["de", "fr", "es"]); + }); + + it("reads the counter of the locale that threw, not of another one", () => { + expect( + locales(run({ taskStatus: tried("fr", 3) }), "de", 3), + "fr is spent, de is on its first try — this failure is de's, so it is not final" + ).toEqual([]); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts new file mode 100644 index 000000000..b0d9b1b48 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; + +import { owedOnCancel } from "../owedOnCancel.js"; +import type { PayloadJob } from "../types.js"; + +const run = (overrides: Partial = {}): PayloadJob => ({ + id: 7, + createdAt: "2026-10-09T10:00:00Z", + updatedAt: "2026-10-09T10:01:00Z", + input: { + collection_slug: "posts", + collection_id: "doc-1", + source_lng: "en", + target_lngs: ["de", "fr"], + strategy: "overwrite", + publish_on_translation: false, + }, + ...overrides, +}); + +describe("owedOnCancel", () => { + it("owes every locale of a run that has not started", () => { + expect(owedOnCancel(run()).map((task) => task.input.targetLng)).toEqual(["de", "fr"]); + }); + + it("owes a locale whose attempt failed while the run can still try again", () => { + const betweenAttempts = run({ + error: { message: "provider down" }, + log: [{ state: "failed", input: { target_lng: "de" } }], + }); + + expect(owedOnCancel(betweenAttempts).map((task) => task.input.targetLng)).toEqual(["de", "fr"]); + }); + + it("does not owe a locale the run delivered", () => { + const halfDone = run({ + log: [ + { state: "succeeded", completedAt: "2026-10-09T10:01:00Z", input: { target_lng: "de" } }, + ], + }); + + expect(owedOnCancel(halfDone).map((task) => task.input.targetLng)).toEqual(["fr"]); + }); + + it("owes nothing once the run has given up", () => { + const spent = run({ + hasError: true, + error: { message: "provider down" }, + log: [{ state: "failed", input: { target_lng: "de" } }], + }); + + expect( + owedOnCancel(spent), + "giving up settled every locale it had not delivered; a second ending is one too many" + ).toEqual([]); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts index 3e6acc29f..1ccd56859 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { planEnqueue } from "../planEnqueue.js"; +import type { EnqueuePlan } from "../planEnqueue.js"; import type { RequestShape } from "../planEnqueue.js"; import type { PayloadJob } from "../types.js"; @@ -34,36 +35,71 @@ const job = (overrides: Partial = {}): PayloadJob => ({ const plan = (live: PayloadJob[], requested: string[], exclusiveQueue = false) => planEnqueue({ live, request, requested, exclusiveQueue }); +const planOf = (over: Partial = {}): EnqueuePlan => ({ + host: null, + append: [], + queue: [], + covered: [], + coveredBy: null, + ...over, +}); + describe("planEnqueue", () => { it("queues everything when the document has no live job", () => { - expect(plan([], ["de", "fr"])).toEqual({ host: null, append: [], queue: ["de", "fr"] }); + expect(plan([], ["de", "fr"])).toEqual(planOf({ queue: ["de", "fr"] })); + }); + + it("gives a locale the job translated and then failed on a run of its own", () => { + const live = job({ + log: [ + { state: "succeeded", input: { target_lng: "de" } }, + { state: "failed", input: { target_lng: "de" } }, + ], + }); + expect( + plan([live], ["de"]), + "it was translated once, so Payload will not run that id again — it belongs in no other list" + ).toEqual(planOf({ host: live, queue: ["de"] })); + }); + + it("will not host on a job whose retries are spent", () => { + const spent = job({ hasError: true }); + expect( + plan([spent], ["de", "fr"]), + "Payload never picks that job up again, so appending to it would promise nothing" + ).toEqual(planOf({ queue: ["de", "fr"] })); }); it("adds locales the live job does not carry yet", () => { const live = job(); - expect(plan([live], ["fr", "es"])).toEqual({ host: live, append: ["fr", "es"], queue: [] }); + expect(plan([live], ["fr", "es"])).toEqual(planOf({ host: live, append: ["fr", "es"] })); }); - it("does nothing for a locale the live job already owes", () => { + it("reports a locale the live job already owes as covered by it", () => { const live = job(); - expect(plan([live], ["de"])).toEqual({ host: live, append: [], queue: [] }); + expect( + plan([live], ["de"]), + "nothing is written for it, yet that job will translate it — so the caller is owed its handle" + ).toEqual(planOf({ host: live, covered: ["de"], coveredBy: live })); }); it("gives a locale the live job has already translated a job of its own", () => { const live = job({ log: [{ state: "succeeded", input: { target_lng: "de" } }], }); - expect(plan([live], ["de"])).toEqual({ host: live, append: [], queue: ["de"] }); + expect(plan([live], ["de"])).toEqual(planOf({ host: live, queue: ["de"] })); }); it("splits a request across both when it mixes the two", () => { const live = job({ log: [{ state: "succeeded", input: { target_lng: "de" } }] }); - expect(plan([live], ["de", "fr"])).toEqual({ host: live, append: ["fr"], queue: ["de"] }); + expect(plan([live], ["de", "fr"])).toEqual( + planOf({ host: live, append: ["fr"], queue: ["de"] }) + ); }); it("ignores duplicates in the request", () => { const live = job(); - expect(plan([live], ["fr", "fr"])).toEqual({ host: live, append: ["fr"], queue: [] }); + expect(plan([live], ["fr", "fr"])).toEqual(planOf({ host: live, append: ["fr"] })); }); it.each([ @@ -96,18 +132,18 @@ describe("planEnqueue", () => { const legacy = job({ input: { ...storedInput, target_lngs: undefined, target_lng: "de" }, }); - expect(plan([legacy], ["fr"])).toEqual({ host: null, append: [], queue: ["fr"] }); + expect(plan([legacy], ["fr"])).toEqual(planOf({ queue: ["fr"] })); }); it("does not extend a cancelled job", () => { const cancelled = job({ error: { cancelled: true } }); - expect(plan([cancelled], ["fr"])).toEqual({ host: null, append: [], queue: ["fr"] }); + expect(plan([cancelled], ["fr"])).toEqual(planOf({ queue: ["fr"] })); }); describe("when the host enabled Payload's concurrency control", () => { it("queues alongside a running job instead of extending it", () => { const running = job({ processing: true }); - expect(plan([running], ["fr"], true)).toEqual({ host: null, append: [], queue: ["fr"] }); + expect(plan([running], ["fr"], true)).toEqual(planOf({ queue: ["fr"], coveredBy: running })); }); it("merges an already-translated locale into the alongside job too", () => { @@ -115,22 +151,68 @@ describe("planEnqueue", () => { processing: true, log: [{ state: "succeeded", input: { target_lng: "de" } }], }); - expect(plan([running], ["de", "fr"], true)).toEqual({ - host: null, - append: [], - queue: ["fr", "de"], - }); + expect(plan([running], ["de", "fr"], true)).toEqual( + planOf({ queue: ["fr", "de"], coveredBy: running }) + ); }); it("still extends a job that has not started", () => { const pending = job({ processing: false }); - expect(plan([pending], ["fr"], true)).toEqual({ host: pending, append: ["fr"], queue: [] }); + expect(plan([pending], ["fr"], true)).toEqual(planOf({ host: pending, append: ["fr"] })); }); }); it("extends a running job when the host has NOT enabled concurrency control", () => { const running = job({ processing: true }); - expect(plan([running], ["fr"])).toEqual({ host: running, append: ["fr"], queue: [] }); + expect(plan([running], ["fr"])).toEqual(planOf({ host: running, append: ["fr"] })); + }); + + describe("every requested locale is accounted for", () => { + const partlyTranslated = () => + job({ + input: { ...storedInput, target_lngs: ["de", "fr"] }, + log: [{ state: "succeeded", input: { target_lng: "de" } }], + }); + + it.each<[string, () => PayloadJob[], string[], boolean]>([ + ["no live job", () => [], ["de", "fr"], false], + ["a live job carrying one of them", () => [job()], ["de", "fr"], false], + [ + "a live job that translated one of them", + () => [partlyTranslated()], + ["de", "fr", "es"], + false, + ], + ["a live job carrying all of them", () => [partlyTranslated()], ["fr"], false], + [ + "a running job under an exclusive queue", + () => [job({ processing: true })], + ["de", "fr"], + true, + ], + ["a cancelled job", () => [job({ error: { cancelled: true } })], ["de"], false], + ])( + "splits the request into exactly three lists, with %s", + (_label, live, requested, exclusive) => { + const result = plan(live(), requested, exclusive); + const landed = [...result.append, ...result.queue, ...result.covered]; + + expect( + [...landed].sort(), + "a locale in none of them is work nobody can report, and one in two of them is reported twice" + ).toEqual([...new Set(requested)].sort()); + expect(landed, "no locale lands in two lists").toHaveLength(new Set(landed).size); + } + ); + + it("names the run covering a locale even when that run may not be appended to", () => { + const running = job({ processing: true }); + + const result = plan([running], ["de"], true); + + expect(result.host, "an exclusive queue forbids writing to it").toBeNull(); + expect(result.coveredBy, "but it is still translating de").toBe(running); + }); }); describe("a job can only take locales from a request it matches", () => { @@ -140,7 +222,7 @@ describe("planEnqueue", () => { ["a different publish flag", { publish_on_translation: true }], ])("starts its own job for %s", (_label, storedOverride) => { const live = job({ input: { ...storedInput, ...storedOverride } }); - expect(plan([live], ["fr"])).toEqual({ host: null, append: [], queue: ["fr"] }); + expect(plan([live], ["fr"])).toEqual(planOf({ queue: ["fr"] })); }); }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/retryLimitOf.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/retryLimitOf.test.ts new file mode 100644 index 000000000..48f4d9713 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/retryLimitOf.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { retryLimitOf } from "../retryLimitOf.js"; + +describe("retryLimitOf", () => { + it.each([ + ["a bare number", 3, 3], + ["a config object", { attempts: 2 }, 2], + ["a config object carrying only a backoff", { backoff: { type: "fixed" as const } }, 0], + ["nothing configured", undefined, 0], + ])("reads %s", (_label, retries, expected) => { + expect(retryLimitOf(retries as never)).toBe(expected); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts new file mode 100644 index 000000000..d0d0cff0a --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import type { Task, TaskInput } from "../../types.js"; +import { stillOwed } from "../stillOwed.js"; + +const RUN_ID = "run-1"; + +const localeOf = (targetLng: string): TaskInput => ({ + collectionSlug: "posts", + collectionId: "doc-1", + sourceLng: "en", + targetLng, + strategy: "overwrite", + publishOnTranslation: false, +}); + +const delivered = (targetLng: string): Task => ({ + id: RUN_ID, + status: "completed", + input: localeOf(targetLng), + createdAt: "2026-10-06T10:00:00Z", + updatedAt: "2026-10-06T10:01:00Z", + completedAt: "2026-10-06T10:01:00Z", + cancelled: false, +}); + +const attemptedAndFailed = (targetLng: string): Task => ({ + id: RUN_ID, + status: "failed", + input: localeOf(targetLng), + createdAt: "2026-10-06T10:00:00Z", + updatedAt: "2026-10-06T10:02:00Z", + error: { message: "provider unreachable" }, + cancelled: false, +}); + +const neverStarted = (targetLng: string): Task => ({ + id: RUN_ID, + status: "pending", + input: localeOf(targetLng), + createdAt: "2026-10-06T10:00:00Z", + updatedAt: "2026-10-06T10:00:00Z", + cancelled: false, +}); + +describe("stillOwed", () => { + it("owes nothing when every locale was delivered", () => { + expect(stillOwed([delivered("de"), delivered("fr")])).toEqual([]); + }); + + it("owes a locale that was attempted and failed", () => { + const failedFr = attemptedAndFailed("fr"); + + expect(stillOwed([delivered("de"), failedFr])).toContainEqual(failedFr); + }); + + it("owes a locale the run was translating when it stopped", () => { + const owed = stillOwed([{ ...neverStarted("de"), status: "running" }]); + expect(owed.map((task) => task.input.targetLng)).toEqual(["de"]); + }); + + it("owes a locale the run never reached", () => { + const pendingEs = neverStarted("es"); + + expect(stillOwed([delivered("de"), attemptedAndFailed("fr"), pendingEs])).toContainEqual( + pendingEs + ); + }); + + it("does not announce a delivered locale, even though the run ended badly", () => { + const deliveredDe = delivered("de"); + + expect( + stillOwed([deliveredDe, attemptedAndFailed("fr"), neverStarted("es")]) + ).not.toContainEqual(deliveredDe); + }); + + it("owes each locale separately, although the run's locales share one id", () => { + expect(stillOwed([attemptedAndFailed("fr"), attemptedAndFailed("es")])).toHaveLength(2); + }); + + it("orders the owed locales the way the run would have translated them", () => { + const owed = stillOwed([neverStarted("es"), delivered("de"), attemptedAndFailed("fr")]); + + expect(owed.map((task) => task.input.targetLng)).toEqual(["es", "fr"]); + }); + + it("hands back the task itself, not a locale name or a runner's row", () => { + const failedFr = attemptedAndFailed("fr"); + + expect(stillOwed([failedFr])).toEqual([failedFr]); + }); + + it("owes nothing for a run that covered nothing", () => { + expect(stillOwed([])).toEqual([]); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts new file mode 100644 index 000000000..6d6c3d55b --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts @@ -0,0 +1,21 @@ +import type { PayloadJob } from "./types.js"; + +/** + * Which requested locales the live run will translate without being touched. + * + * A failed attempt still counts as covered — the run comes back to it. A locale it has translated + * does not: Payload will not re-run a task id under the same run. + * + * The caller guarantees the run is one this request may join and that its locale list is present. + */ +export type AlreadyCovered = (run: PayloadJob, requested: string[]) => string[]; + +export const alreadyCovered: AlreadyCovered = (run, requested) => { + const listed = new Set(run.input?.target_lngs); + const translated = new Set( + (run.log ?? []) + .filter((entry) => entry.state === "succeeded") + .map((entry) => entry.input?.target_lng) + ); + return [...new Set(requested)].filter((locale) => listed.has(locale) && !translated.has(locale)); +}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/assignmentOf.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/assignmentOf.ts new file mode 100644 index 000000000..02cec4bbe --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/assignmentOf.ts @@ -0,0 +1,31 @@ +import type { CollectionSlug } from "payload"; + +import type { EnqueueAssignment } from "../types.js"; +import { handleOf } from "./handleOf.js"; +import { readCollectionRef } from "./readCollectionRef.js"; +import type { PayloadJob } from "./types.js"; + +/** + * One locale of a stored run — or nothing, when the row cannot describe itself. + * + * A row this plugin queued always carries all of it, but `PayloadJob` also models rows from before + * the id-agnostic migration. Rather than substitute a default, an undescribable row yields nothing + * and its caller sends no event: a guessed `strategy` would reach the host as fact, and an empty + * handle would be an assignment the contract says cannot exist. + */ +export const assignmentOf = (job: PayloadJob, targetLng: string): EnqueueAssignment | undefined => { + const { collectionSlug, collectionId } = readCollectionRef(job.input); + const sourceLng = job.input?.source_lng; + const strategy = job.input?.strategy; + const handle = handleOf(job); + if (!(sourceLng && strategy && handle)) return undefined; + + return { + collectionSlug: collectionSlug as CollectionSlug, + collectionId, + sourceLng, + targetLng, + strategy, + handle, + }; +}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/handleOf.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/handleOf.ts new file mode 100644 index 000000000..684731482 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/handleOf.ts @@ -0,0 +1,8 @@ +/** + * Payload's row id is numeric on an autoincrement database and textual elsewhere, so a handle is + * always the stringified id. + */ +export const handleOf = (candidate: { id?: unknown } | undefined): string | null => + typeof candidate?.id === "string" || typeof candidate?.id === "number" + ? String(candidate.id) + : null; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/isLastAttempt.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/isLastAttempt.ts new file mode 100644 index 000000000..6d429a431 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/isLastAttempt.ts @@ -0,0 +1,12 @@ +/** + * Whether the attempt that just threw was the run's last for this locale. + * + * Payload counts the attempts *before* the current one, so `attempts: 3` executes a locale four + * times (`payload/dist/queues/errors/handleTaskError.js:54`). + */ +export type IsLastAttempt = (tried: { totalTried?: unknown } | undefined, limit: number) => boolean; + +export const isLastAttempt: IsLastAttempt = (tried, limit) => { + const before = typeof tried?.totalTried === "number" ? tried.totalTried : 0; + return before >= limit; +}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/normalizeJob.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/normalizeJob.ts index 18cbf4bff..3277ef3ab 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/normalizeJob.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/normalizeJob.ts @@ -35,7 +35,7 @@ export function normalizeJob(job: PayloadJob): Task { const { collectionSlug, collectionId } = readCollectionRef(job.input); return { - id: job.id, + id: String(job.id), status: getJobStatus(job), input: { collectionSlug, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedIfGaveUp.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedIfGaveUp.ts new file mode 100644 index 000000000..569600d85 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedIfGaveUp.ts @@ -0,0 +1,28 @@ +import { stillOwed } from "./stillOwed.js"; +import type { Task } from "../types.js"; +import { isLastAttempt } from "./isLastAttempt.js"; +import { normalizeJobLocales } from "./normalizeJob.js"; +import type { PayloadJob } from "./types.js"; + +/** + * Which locales a run will never deliver, given that `target` has just thrown — nothing while the + * run still has an attempt left. + * + * Only the loop walking a run's locales can answer it: it stops at the locale that threw, so the + * ones after it never start and nothing else ever sees them. + * + * Payload gives up two ways and either one ends the run, so both are asked: the locale's attempts + * against the task limit (`handleTaskError.js:54`) and the run's executions against the workflow + * limit (`getWorkflowRetryBehavior.js:8`). + */ +export function owedIfGaveUp( + job: PayloadJob, + taskName: string, + target: string, + retryLimit: number +): Task[] { + const spentOnThisLocale = isLastAttempt(job.taskStatus?.[taskName]?.[target], retryLimit); + const spentOnTheRun = isLastAttempt(job, retryLimit); + if (!spentOnThisLocale && !spentOnTheRun) return []; + return stillOwed(normalizeJobLocales(job)); +} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedOnCancel.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedOnCancel.ts new file mode 100644 index 000000000..cb0cb0266 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedOnCancel.ts @@ -0,0 +1,14 @@ +import type { Task } from "../types.js"; +import { normalizeJobLocales } from "./normalizeJob.js"; +import { stillOwed } from "./stillOwed.js"; +import type { PayloadJob } from "./types.js"; + +/** + * Which of a run's locales stopping it still owes an answer about. + * + * A run that has given up owes nothing: spending the last attempt settles every locale it had not + * delivered (see {@link owedIfGaveUp}), and Payload leaves `hasError` set from that moment on. A run + * merely waiting to try again owes its undelivered locales, logged failures among them. + */ +export const owedOnCancel = (job: PayloadJob): Task[] => + job.hasError === true ? [] : stillOwed(normalizeJobLocales(job)); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/planEnqueue.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/planEnqueue.ts index dffe36d0c..0c91915f8 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/planEnqueue.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/planEnqueue.ts @@ -1,6 +1,7 @@ import type { CollectionSlug } from "payload"; -import { isCancelled, latestLogByLocale } from "./normalizeJob.js"; +import { alreadyCovered } from "./alreadyCovered.js"; +import { isCancelled } from "./normalizeJob.js"; import type { PayloadJob } from "./types.js"; export type RequestShape = { @@ -14,13 +15,19 @@ export type RequestShape = { }; /** - * `append` and `queue` are independent: a request can both extend a live job and need a job of its - * own, when a locale it asks for has already been translated by that job. + * What to do about each locale a request asked for. **Every requested locale appears in exactly one + * of `append`, `queue` and `covered`** — one in none of them is work the caller cannot report. + * + * `queue` is for a locale the live run has already translated: it will not translate it again. + * `covered` needs nothing done, and `coveredBy` names the run doing it — kept apart from `host`, + * which is null when the run may not be written to although it is still translating. */ export type EnqueuePlan = { host: PayloadJob | null; append: string[]; queue: string[]; + covered: string[]; + coveredBy: PayloadJob | null; }; /** @@ -38,19 +45,31 @@ export function planEnqueue(args: { }): EnqueuePlan { const requested = [...new Set(args.requested)]; const host = pickHost(args.live, args.request); - if (!host) return { host: null, append: [], queue: requested }; + if (!host) return { host: null, append: [], queue: requested, covered: [], coveredBy: null }; - // Already-succeeded locales need a fresh job: the workflow passes the locale as the task id, so - // Payload's restoration would skip them (see the workflow handler in PayloadJobsRunnerProvider). - const settled = latestLogByLocale(host); - const done = requested.filter((locale) => settled.get(locale)?.state === "succeeded"); const listed = new Set(host.input?.target_lngs); const missing = requested.filter((locale) => !listed.has(locale)); + const covered = alreadyCovered(host, requested); + const alreadyTranslated = requested.filter( + (locale) => listed.has(locale) && !covered.includes(locale) + ); if (args.exclusiveQueue && host.processing) { - return { host: null, append: [], queue: [...missing, ...done] }; + return { + host: null, + append: [], + queue: [...missing, ...alreadyTranslated], + covered, + coveredBy: host, + }; } - return { host, append: missing, queue: done }; + return { + host, + append: missing, + queue: alreadyTranslated, + covered, + coveredBy: covered.length ? host : null, + }; } /** @@ -62,6 +81,7 @@ function pickHost(live: PayloadJob[], request: RequestShape): PayloadJob | null (job) => Array.isArray(job.input?.target_lngs) && !isCancelled(job.error) && + job.hasError !== true && job.input?.source_lng === request.sourceLng && job.input?.strategy === request.strategy && (job.input?.publish_on_translation ?? false) === request.publishOnTranslation && diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/retryLimitOf.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/retryLimitOf.ts new file mode 100644 index 000000000..0eb30fbd6 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/retryLimitOf.ts @@ -0,0 +1,11 @@ +import type { PayloadJobsRunnerOptions } from "./types.js"; + +/** + * How many retries the plugin configured, in the one form {@link isLastAttempt} compares against. + * + * Payload takes either a bare number or a config object and treats a missing `attempts` as none + * (`payload/dist/queues/errors/handleTaskError.js:31-41`), so an unconfigured runner reports its + * first failure as final. + */ +export const retryLimitOf = (retries: PayloadJobsRunnerOptions["retries"]): number => + typeof retries === "number" ? retries : (retries?.attempts ?? 0); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/stillOwed.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/stillOwed.ts new file mode 100644 index 000000000..a4911a29b --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/stillOwed.ts @@ -0,0 +1,10 @@ +import type { Task } from "../types.js"; + +/** + * Which of a run's locales it has not delivered; a locale it never reached is owed too. + * + * The caller passes one run's tasks, one per target locale. + */ +export type StillOwed = (tasks: Task[]) => Task[]; + +export const stillOwed: StillOwed = (tasks) => tasks.filter((task) => task.status !== "completed"); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/types.ts index 8b8e3f0d6..c758d0718 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/types.ts @@ -43,9 +43,19 @@ export type PayloadJobsRunnerConfig = { retries?: PayloadJobsRunnerOptions["retries"]; }; +/** Payload keys a run's per-task state by the task's slug, and each task by the id it ran under — + * which for this plugin's runs is the target locale. */ +type TaskSlug = string; +type TaskId = string; + export type PayloadJob = { + /** Payload sets this once a run's retries are spent, and never clears it. */ + hasError?: boolean; log?: JobLogEntry[]; - id: string; + /** How many times the whole run has been executed — Payload's second give-up counter. */ + totalTried?: unknown; + taskStatus?: Record>; + id: string | number; completedAt?: string | null; createdAt: string; updatedAt: string; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncRunnerProvider.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncRunnerProvider.ts index 3c55e9fcf..cc5d618f7 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncRunnerProvider.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncRunnerProvider.ts @@ -1,7 +1,7 @@ import type { Config, Payload } from "payload"; import type { TaskRunner } from "../TaskRunner.interface.js"; -import type { TaskRunnerProvider, TaskHandler } from "../TaskRunnerProvider.interface.js"; +import type { TaskRunnerContext, TaskRunnerProvider } from "../TaskRunnerProvider.interface.js"; import type { Task } from "../types.js"; import type { SyncRunnerOptions } from "./types.js"; import { SyncTaskRunner } from "./SyncTaskRunner.js"; @@ -31,8 +31,8 @@ export class SyncRunnerProvider implements TaskRunnerProvider { }); } - create(payload: Payload, handler: TaskHandler): TaskRunner { - return new SyncTaskRunner(payload, handler, this.tasks); + create(payload: Payload, context: TaskRunnerContext): TaskRunner { + return new SyncTaskRunner(payload, context, this.tasks); } configure(): (config: Config) => Config { diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts index 3844835fa..9a4fea391 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts @@ -2,8 +2,8 @@ import type { Payload, CollectionSlug } from "payload"; import type { TaskFilter, TaskRunner } from "../TaskRunner.interface.js"; import { toTaskFilter } from "../toTaskFilter.js"; -import type { TaskHandler } from "../TaskRunnerProvider.interface.js"; -import type { Task, TaskInput, RunResult, ID } from "../types.js"; +import type { TaskRunnerContext } from "../TaskRunnerProvider.interface.js"; +import type { EnqueueAssignment, Task, TaskInput, RunResult, ID } from "../types.js"; import type { LazyMap } from "../../../shared/utils/index.js"; import type { RequestScope } from "../../../shared/payload/RequestScope.shapes.js"; import { swallowOrThrow } from "../../../shared/payload/swallowOrThrow.js"; @@ -11,18 +11,22 @@ import { swallowOrThrow } from "../../../shared/payload/swallowOrThrow.js"; /** Runs each task inline on enqueue; results live only in memory, so status queries see nothing from a previous process. */ export class SyncTaskRunner implements TaskRunner { private readonly payload: Payload; - private readonly handler: TaskHandler; + private readonly context: TaskRunnerContext; private readonly tasks: LazyMap; - constructor(payload: Payload, handler: TaskHandler, tasks: LazyMap) { + constructor(payload: Payload, context: TaskRunnerContext, tasks: LazyMap) { this.payload = payload; - this.handler = handler; + this.context = context; this.tasks = tasks; } - async enqueue(inputs: TaskInput[], scope: RequestScope = {}): Promise { + async enqueue(inputs: TaskInput[], scope: RequestScope = {}): Promise { + const assigned: EnqueueAssignment[] = []; + const served = new Set(); for (const input of inputs) { const key = this.getKey(input.collectionSlug, input.collectionId, input.targetLng); + if (served.has(key)) continue; + served.add(key); const now = new Date().toISOString(); const task: Task = { @@ -35,6 +39,16 @@ export class SyncTaskRunner implements TaskRunner { }; this.tasks.set(key, task); + const assignment: EnqueueAssignment = { + collectionSlug: input.collectionSlug, + collectionId: input.collectionId, + sourceLng: input.sourceLng, + targetLng: input.targetLng, + strategy: input.strategy, + handle: task.id, + }; + assigned.push(assignment); + await this.context.report(this.payload, assignment, { state: "queued" }); const markEvictable = (status: "completed" | "failed", error?: Task["error"]) => { const at = new Date().toISOString(); @@ -47,7 +61,7 @@ export class SyncTaskRunner implements TaskRunner { await swallowOrThrow( scope, async () => { - await this.handler( + await this.context.handler( this.payload, { collection: input.collectionSlug, @@ -60,17 +74,22 @@ export class SyncTaskRunner implements TaskRunner { scope ); markEvictable("completed"); + await this.context.report(this.payload, assignment, { state: "delivered" }); }, - (error) => + async (error) => { markEvictable("failed", { message: error instanceof Error ? error.message : "Unknown error", - }) + }); + await this.context.report(this.payload, assignment, { state: "failed", error }); + } ); } + return assigned; } - async cancel(_taskIds: string[]): Promise { - // No-op: synchronous tasks execute immediately and cannot be cancelled + /** No-op, and reports nothing: every assignment settled inside `enqueue`, so none is still owed. */ + cancel(_taskIds: string[]): Promise { + return Promise.resolve(); } async run(_taskId: string): Promise { diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts index a3811a1cf..c8fc730a7 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts @@ -9,6 +9,7 @@ const fakePayload = {} as Payload; const makeContext = (handler: TaskRunnerContext["handler"]): TaskRunnerContext => ({ handler, + report: vi.fn().mockResolvedValue(undefined), collections: ["pages" as CollectionSlug], }); @@ -27,14 +28,14 @@ describe("SyncRunnerProvider", () => { // configure() call mutating instance state. it("create() works without a prior configure() call", () => { const runner = createSyncRunner(); - expect(() => runner.create(fakePayload, vi.fn())).not.toThrow(); + expect(() => runner.create(fakePayload, makeContext(vi.fn()))).not.toThrow(); }); it("runs translations through the supplied handler on enqueue", async () => { const handler = vi.fn().mockResolvedValue(undefined); const runner = createSyncRunner(); - const taskRunner = runner.create(fakePayload, handler); + const taskRunner = runner.create(fakePayload, makeContext(handler)); await taskRunner.enqueue([sampleInput]); expect(handler).toHaveBeenCalledTimes(1); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts new file mode 100644 index 000000000..2a67d0884 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts @@ -0,0 +1,17 @@ +import type { Payload } from "payload"; +import { vi } from "vitest"; + +import { assertTaskRunnerContract } from "../../__tests__/TaskRunner.invariants.js"; +import type { Task } from "../../types.js"; +import { LazyMap } from "../../../../shared/utils/index.js"; +import { SyncTaskRunner } from "../SyncTaskRunner.js"; + +assertTaskRunnerContract( + "SyncTaskRunner", + (report) => + new SyncTaskRunner( + {} as Payload, + { handler: vi.fn().mockResolvedValue(undefined), collections: [], report }, + new LazyMap({ isRemovable: () => false, getTimestamp: () => 0 }) + ) +); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts index 0eab22585..81b0f3fff 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts @@ -7,6 +7,12 @@ import type { TaskHandler } from "../../TaskRunnerProvider.interface.js"; import type { TaskInput, Task } from "../../types.js"; import { LazyMap } from "../../../../shared/utils/index.js"; +const contextOf = (handler: TaskHandler = vi.fn().mockResolvedValue(undefined)) => ({ + handler, + collections: [], + report: vi.fn().mockResolvedValue(undefined), +}); + describe("SyncTaskRunner", () => { let mockPayload: Payload; let mockHandler: TaskHandler; @@ -20,7 +26,7 @@ describe("SyncTaskRunner", () => { isRemovable: (task) => task.status === "completed" || task.status === "failed", getTimestamp: (task) => new Date(task.updatedAt).getTime(), }); - runner = new SyncTaskRunner(mockPayload, mockHandler, tasks); + runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); }); const createInput = (overrides: Partial = {}): TaskInput => ({ @@ -73,7 +79,7 @@ describe("SyncTaskRunner", () => { it("stores a failed task when the handler throws one of ours", async () => { mockHandler = vi.fn().mockRejectedValue(new TransportError("Translation failed")); - runner = new SyncTaskRunner(mockPayload, mockHandler, tasks); + runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); const input = createInput(); await runner.enqueue([input]); @@ -94,7 +100,7 @@ describe("SyncTaskRunner", () => { vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z")); if (rejection) throw rejection; }); - runner = new SyncTaskRunner(mockPayload, mockHandler, tasks); + runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); await runner.enqueue([createInput()]); @@ -111,12 +117,12 @@ describe("SyncTaskRunner", () => { it("finishes the batch for a caller with no transaction to lose", async () => { const fromPayload = new APIError("Validation failed", 400); mockHandler = vi.fn().mockRejectedValueOnce(fromPayload).mockResolvedValueOnce(undefined); - runner = new SyncTaskRunner(mockPayload, mockHandler, tasks); + runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); await expect( runner.enqueue([createInput({ targetLng: "de" }), createInput({ targetLng: "fr" })]), "nothing of this caller's was rolled back, so one refused locale must not cancel the rest" - ).resolves.toBeUndefined(); + ).resolves.toHaveLength(2); expect(mockHandler).toHaveBeenCalledTimes(2); expect(tasks.get("posts:doc-123:de")?.status).toBe("failed"); @@ -125,12 +131,12 @@ describe("SyncTaskRunner", () => { it("swallows one of ours even inside the caller's transaction", async () => { mockHandler = vi.fn().mockRejectedValue(new TransportError("provider down")); - runner = new SyncTaskRunner(mockPayload, mockHandler, tasks); + runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); await expect( runner.enqueue([createInput()], { transactionID: "tx-1" }), "a provider failure ran no Payload operation, so the editor's save is still intact" - ).resolves.toBeUndefined(); + ).resolves.toHaveLength(1); expect(tasks.get("posts:doc-123:de")?.status).toBe("failed"); }); @@ -138,7 +144,7 @@ describe("SyncTaskRunner", () => { it("lets a failure that did not come from the translator out", async () => { const fromPayload = new APIError("Validation failed", 400); mockHandler = vi.fn().mockRejectedValue(fromPayload); - runner = new SyncTaskRunner(mockPayload, mockHandler, tasks); + runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); await expect( runner.enqueue([createInput()], { transactionID: "tx-1" }), @@ -148,7 +154,7 @@ describe("SyncTaskRunner", () => { it("records a non-Error throw and still lets it out", async () => { mockHandler = vi.fn().mockRejectedValue("string error"); - runner = new SyncTaskRunner(mockPayload, mockHandler, tasks); + runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); const input = createInput(); await expect(runner.enqueue([input], { transactionID: "tx-1" })).rejects.toBe("string error"); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts index b739ce3a9..32a5a39bc 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts @@ -20,7 +20,14 @@ function makeRunner() { isRemovable: (t) => t.status === "completed" || t.status === "failed", getTimestamp: (t) => new Date(t.updatedAt).getTime(), }); - return { handler, runner: new SyncTaskRunner({} as Payload, handler, tasks) }; + return { + handler, + runner: new SyncTaskRunner( + {} as Payload, + { handler, collections: [], report: vi.fn().mockResolvedValue(undefined) }, + tasks + ), + }; } describe("SyncTaskRunner — the caller's transaction", () => { diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts index 43e73f415..c6ee58ac6 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts @@ -39,6 +39,8 @@ export type TaskInput = { * Normalized task representation */ export type Task = { + /** The run's handle. One run covers a document's whole locale list, so every task of that run + * carries the same id. */ id: string; status: TaskStatus; input: TaskInput; @@ -49,6 +51,40 @@ export type Task = { cancelled: boolean; }; +/** + * One entry per requested target locale — including a locale an existing run already covers, which + * is being translated and so is owed that run's handle. + * + * `handle` is the runner's own name for the work: hand it back to `TaskRunner.cancel` unchanged, + * never parse it. + * + * @since 0.16.0 + */ +export type EnqueueAssignment = { + collectionSlug: CollectionSlug; + collectionId: ID; + sourceLng: string; + targetLng: string; + strategy: string; + handle: string; +}; + +/** + * What became of one {@link EnqueueAssignment}. + * + * One terminal event per assignment, except a locale already being translated when its run is + * cancelled: cancelling does not stop work in flight, so it may be reported `cancelled` and still + * settle `delivered`. + * + * For a locale a run never reached, `failed`'s `error` is the failure that ended the run, not its + * own — nothing ran for it to throw. + */ +export type TaskEvent = + | { state: "queued" } + | { state: "delivered" } + | { state: "failed"; error: unknown } + | { state: "cancelled" }; + /** * Result of run operation */