From d6d17d1e0866f5dd52bab7f6bf74e9fdb1391239 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Thu, 8 Oct 2026 12:21:44 +0200 Subject: [PATCH 01/10] feat(translator): answer an enqueue with the runs it arranged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enqueue` returned a count. A host that wanted to cancel a run later, or record on its own row which run is translating a locale, had nothing to store. It now answers with one entry per requested target locale, each naming the handle that will run it. Per locale rather than per handle, because a single job row covers a document's whole locale list: two locales of one document name the same run, and a request spanning several documents still reads unambiguously. `TaskRunner.enqueue` widens its return rather than changing it. The old `void` answer still satisfies the contract and is deprecated for the next major, with a ledger entry. An overload pair is not usable here: an implementation must satisfy every overload, so it would break every third-party runner. `EnqueueAssignment` and `Task` are exported so an implementation outside this package can name what it answers with. A locale an existing run already covers is answered too, with that run's handle — it is being translated, and leaving it out would report nothing for work genuinely under way. `EnqueuePlan` gained `covered`/`coveredBy` for it, and every requested locale now appears in exactly one of `append`, `queue` and `covered`. Two things fell out of making the answer honest. A run whose retries are spent keeps `hasError` and never gets a completion date, so it stayed in the plugin's live set although Payload's own picker had excluded it for good. Appending to it promised a translation that could not happen; `pickHost` now refuses it. A run's handle had three spellings: two helpers stringified the row id and `normalizeJob` passed it through, so one run arrived as `"12"` down one path and `12` down another. One helper forms it now. This changes the wire: `GET /translate/document/:slug/:id` and `GET /translate/collection/:slug` answer `"id": "12"` where they answered `"id": 12`. The declared type always said `string`, so nothing documented changes. --- .../integration/translator/callEndpoint.ts | 2 +- .../translator/enqueue-handles.int.test.ts | 98 +++++ .../retranslate-after-giving-up.int.test.ts | 70 ++++ .../docs/DEPRECATIONS.md | 42 ++ .../2026-10-06-lifecycle-run-context.task.md | 363 ++++++++++++++++++ .../2026-10-06-alreadyCovered-red-run.txt | 193 ++++++++++ .../2026-10-06-stillOwed-red-run.txt | 133 +++++++ .../payload-plugin-translator/src/index.ts | 7 + .../features/enqueue-translation/handler.ts | 10 +- .../features/enqueue-translation/model.ts | 18 + .../task-runner/TaskRunner.interface.ts | 31 +- .../__tests__/TaskRunner.conformance.types.ts | 43 +++ .../src/server/modules/task-runner/index.ts | 2 +- .../PayloadJobsTaskRunner.ts | 61 ++- .../__tests__/alreadyCovered.test.ts | 124 ++++++ .../__tests__/handleOf.test.ts | 21 + .../__tests__/planEnqueue.test.ts | 118 +++++- .../payload-jobs-runner/alreadyCovered.ts | 21 + .../payload-jobs-runner/handleOf.ts | 8 + .../payload-jobs-runner/normalizeJob.ts | 2 +- .../payload-jobs-runner/planEnqueue.ts | 40 +- .../task-runner/payload-jobs-runner/types.ts | 12 +- .../task-runner/sync-runner/SyncTaskRunner.ts | 21 +- .../__tests__/SyncTaskRunner.test.ts | 7 +- .../src/server/modules/task-runner/types.ts | 18 + 25 files changed, 1404 insertions(+), 61 deletions(-) create mode 100644 apps/dev/src/integration/translator/enqueue-handles.int.test.ts create mode 100644 apps/dev/src/integration/translator/retranslate-after-giving-up.int.test.ts create mode 100644 packages/payload-plugin-translator/docs/plans/2026-10-06-lifecycle-run-context.task.md create mode 100644 packages/payload-plugin-translator/docs/plans/artifacts/2026-10-06-alreadyCovered-red-run.txt create mode 100644 packages/payload-plugin-translator/docs/plans/artifacts/2026-10-06-stillOwed-red-run.txt create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/handleOf.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/alreadyCovered.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/handleOf.ts 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/enqueue-handles.int.test.ts b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts new file mode 100644 index 000000000..0b43cf3ec --- /dev/null +++ b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts @@ -0,0 +1,98 @@ +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 Job = { collection_slug: string; collection_id: string; target_lng: string; job_id: 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: { jobs?: Job[] } }).data.jobs ?? []; +}; + +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 jobs = await enqueue([id], ["de", "fr"]); + + expect(jobs.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: jobs.map((j) => j.job_id) } } as never, + }); + expect(rows.docs, "every handle answered names a row that is actually there").toHaveLength( + new Set(jobs.map((j) => j.job_id)).size + ); + }); + + it("gives two locales of one document the same run", async () => { + const id = await create("Shared run"); + + const jobs = await enqueue([id], ["de", "fr"]); + + expect( + new Set(jobs.map((j) => j.job_id)), + "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 jobs = await enqueue([first, second], ["de", "fr"]); + + expect(jobs, "two documents, two locales each").toHaveLength(4); + expect(new Set(jobs.map((j) => j.job_id))).toHaveLength(2); + expect( + new Set(jobs.filter((j) => j.collection_id === first).map((j) => j.job_id)) + ).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")?.job_id, + "and it is the run that already had it" + ).toBe(first[0]?.job_id); + }); +}); 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..3d2a1fd71 --- /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 Job = { target_lng: string; job_id: 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: { jobs?: Job[] } }).data.jobs ?? []; +}; + +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]?.job_id, + "the first run is spent — Payload will never pick it up again, so naming it promises nothing" + ).not.toBe(first[0]?.job_id); + 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/packages/payload-plugin-translator/docs/DEPRECATIONS.md b/packages/payload-plugin-translator/docs/DEPRECATIONS.md index e729f4abd..de23edd99 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -250,3 +250,45 @@ 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 `EnqueueAssignment` per requested locale. +- **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 reports it itself; see + `TaskRunnerProvider.reportsFinalFailure`. +- **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` (reports only when the runner does not) + - `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..b250241bb --- /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, job_id }[]`. 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/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/src/index.ts b/packages/payload-plugin-translator/src/index.ts index d44b9ad05..128cf9713 100644 --- a/packages/payload-plugin-translator/src/index.ts +++ b/packages/payload-plugin-translator/src/index.ts @@ -57,6 +57,13 @@ export type { PayloadJobsRunnerOptions, TaskFilter, } from "./server/modules/task-runner/index.js"; +/** + * The names a third-party runner answers with: `EnqueueAssignment` from `enqueue`, `Task` from + * `findByIds`. + * + * @since 0.16.0 + */ +export type { EnqueueAssignment, Task } from "./server/modules/task-runner/index.js"; /** * A third-party `TaskRunnerProvider` has always had to accept a `RequestScope` — it is the second * parameter of `TaskRunner.enqueue` and of the `TaskHandler` it is handed — but could not name the 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..3ae20b92d 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, toQueuedJobs } 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 ? { jobs: toQueuedJobs(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..c359275aa 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 queued locale; `job_id` is what `/translate/cancel` takes back — opaque, never parsed. */ +export type QueuedJob = { + collection_slug: string; + collection_id: string; + target_lng: string; + job_id: string; +}; + +export const toQueuedJobs = (assigned: EnqueueAssignment[]): QueuedJob[] => + assigned.map((entry) => ({ + collection_slug: entry.collectionSlug, + collection_id: entry.collectionId, + target_lng: entry.targetLng, + job_id: entry.handle, + })); 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..1f4247106 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,22 +10,45 @@ 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. */ 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. + * "Cancelled" is not "did not happen" — only {@link TaskRunner.findByIds} says what was still owed. */ cancel(taskIds: string[]): Promise; + /** + * One {@link Task} per handle **and target locale**, not one per handle. + * + * Optional: a runner that omits it works unchanged, and `onCancelled` never fires for it. + * + * @since 0.16.0 + */ + findByIds?(taskIds: string[]): Promise; + /** * Execute a task immediately. * Returns error status if task not found, already running, or completed. 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..1e43b5330 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts @@ -0,0 +1,43 @@ +import type { Config } from "payload"; + +import type { EnqueueAssignment, Task, TaskRunnerProvider } from "../../../../index.js"; + +/** + * A compile-only fixture, enforced by `check-types` rather than by a running test. + * + * A runner is implemented outside this package, so it is written against the published root barrel — + * which is what this file imports, rather than the internal paths no installer can reach. Widening + * `enqueue` must not break an implementation written before the widening: both generations below + * must keep satisfying the contract, and narrowing `enqueue` to require assignments makes this file + * fail to compile, which is the point of it existing. + */ + +const unqueues = { + cancel: async (): Promise => undefined, + run: async () => ({ success: false, error: "not_found" }) as const, + findByCollection: async (): Promise => [], +}; + +const configure = () => (config: Config) => config; + +/** Pre-0.16.0: answers with nothing, and has no by-handle read. */ +export const silent: TaskRunnerProvider = { + create: () => ({ ...unqueues, enqueue: async () => undefined }), + configure, +}; + +/** Post-0.16.0: one assignment per requested locale, and resolves handles. */ +export const answering: TaskRunnerProvider = { + create: () => ({ + ...unqueues, + enqueue: async (tasks): Promise => + tasks.map((task) => ({ + collectionSlug: task.collectionSlug, + collectionId: task.collectionId, + targetLng: task.targetLng, + handle: "run-1", + })), + findByIds: async (): Promise => [], + }), + configure, +}; 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/PayloadJobsTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts index 25ca60a21..886b92fad 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 @@ -2,8 +2,9 @@ import type { Payload, Where, CollectionSlug } from "payload"; import type { TaskFilter, TaskRunner } from "../TaskRunner.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 { handleOf } from "./handleOf.js"; import { normalizeJobLocales } from "./normalizeJob.js"; import { planEnqueue } from "./planEnqueue.js"; import type { RequestShape } from "./planEnqueue.js"; @@ -20,7 +21,15 @@ 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, + targetLng, + handle, + })); function requestShape(task: TaskInput, scope: RequestScope): RequestShape { return { @@ -48,7 +57,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { private readonly config: PayloadJobsRunnerConfig ) {} - 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 +76,16 @@ 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); } + return assigned; } private async serve( @@ -81,7 +93,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 +107,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 +146,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 +162,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,13 +175,21 @@ 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 findByIds(taskIds: string[]): Promise { + if (taskIds.length === 0) return []; + const jobs = await this.findRawJobs({ id: { in: taskIds } }); + return jobs.flatMap(normalizeJobLocales); } async cancel(taskIds: string[]): Promise { 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__/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/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/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/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/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/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/SyncTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts index 3844835fa..366023f36 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 @@ -3,7 +3,7 @@ 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 { 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"; @@ -20,9 +20,13 @@ export class SyncTaskRunner implements TaskRunner { 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,12 @@ export class SyncTaskRunner implements TaskRunner { }; this.tasks.set(key, task); + assigned.push({ + collectionSlug: input.collectionSlug, + collectionId: input.collectionId, + targetLng: input.targetLng, + handle: task.id, + }); const markEvictable = (status: "completed" | "failed", error?: Task["error"]) => { const at = new Date().toISOString(); @@ -56,6 +66,7 @@ export class SyncTaskRunner implements TaskRunner { targetLng: input.targetLng, strategy: input.strategy, publishOnTranslation: input.publishOnTranslation, + handle: task.id, }, scope ); @@ -67,10 +78,12 @@ export class SyncTaskRunner implements TaskRunner { }) ); } + return assigned; } - async cancel(_taskIds: string[]): Promise { - // No-op: synchronous tasks execute immediately and cannot be cancelled + /** `findByIds` is deliberately absent: announcing a cancellation would report work that completed. */ + 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__/SyncTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts index 0eab22585..1f13c97d2 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 @@ -36,7 +36,7 @@ describe("SyncTaskRunner", () => { describe("enqueue", () => { it("executes handler immediately", async () => { const input = createInput(); - await runner.enqueue([input]); + const assigned = await runner.enqueue([input]); expect(mockHandler).toHaveBeenCalledWith( mockPayload, @@ -47,6 +47,7 @@ describe("SyncTaskRunner", () => { targetLng: "de", strategy: "overwrite", publishOnTranslation: false, + handle: assigned[0]?.handle, }, {} ); @@ -116,7 +117,7 @@ describe("SyncTaskRunner", () => { 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"); @@ -130,7 +131,7 @@ describe("SyncTaskRunner", () => { 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"); }); 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..569daa354 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,22 @@ 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; + targetLng: string; + handle: string; +}; + /** * Result of run operation */ From 4cd0134d9f513988ecdec3bc3f1ea2919fc21329 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Thu, 8 Oct 2026 12:22:12 +0200 Subject: [PATCH 02/10] feat(translator): carry the run's handle into the lifecycle callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A callback said which document and locale it was about, but nothing identifying the request behind it. A host layering editorial process on top had to write an intent record before enqueue and join on (collection, document, locale) plus "is a run open" — which forced one open run per document and locale, a constraint it would rather not need. Every callback now carries the handle the enqueue answer named, so the join is direct. `onQueued` is the exception and carries none: it fires before the runner is asked, which is what keeps a synchronous runner's `completed` from arriving first, and at that moment no run exists. `onFailed` now means the locale will not be translated. It used to fire on every failed attempt, so "failed" did not mean failed and the consumer ran with retries disabled. Only a runner that retries can tell a transient failure from a final one, so it reports the final one itself and declares `reportsFinalFailure`; a runner that does not retry is reported where the throw happens, as before. There is deliberately no attempt counter: an attempt number still would not say whether another try is coming. When a run gives up it reports every locale it still owed, not only the one that threw. The locales after it never started, so nothing ran for them to throw from and no caller could otherwise learn they are dead. Payload gives up two ways — the locale's attempts against the task limit and the whole run's executions against the workflow limit — and the two diverge as soon as attempts are spent on more than one locale, so both are asked. The error a host receives is the plugin's own. Payload replaces a task's error with a message-only copy, which silently stopped matching the exported error classes, so the handler's error is kept and handed back. --- .../integration/translator/bootTestPayload.ts | 3 + .../translator/completed-handle.int.test.ts | 55 +++++++++ .../translator/final-failure.int.test.ts | 112 ++++++++++++++++++ .../translator/gave-up-mid-run.int.test.ts | 74 ++++++++++++ .../translate-document/wireTranslateRunner.ts | 10 +- .../modules/lifecycle/stillOwed.test.ts | 96 +++++++++++++++ .../src/server/modules/lifecycle/stillOwed.ts | 10 ++ .../modules/lifecycle/taskMapping.test.ts | 51 +++++++- .../server/modules/lifecycle/taskMapping.ts | 12 +- .../src/server/modules/lifecycle/types.ts | 30 ++++- .../TaskRunnerProvider.interface.ts | 28 ++++- .../PayloadJobsRunnerProvider.ts | 58 ++++++--- .../__tests__/isLastAttempt.test.ts | 30 +++++ .../__tests__/owedIfGaveUp.test.ts | 67 +++++++++++ .../__tests__/retryLimitOf.test.ts | 14 +++ .../payload-jobs-runner/isLastAttempt.ts | 12 ++ .../payload-jobs-runner/owedIfGaveUp.ts | 28 +++++ .../payload-jobs-runner/retryLimitOf.ts | 11 ++ .../payload-jobs-runner/thrownByTheHandler.ts | 19 +++ 19 files changed, 696 insertions(+), 24 deletions(-) create mode 100644 apps/dev/src/integration/translator/completed-handle.int.test.ts create mode 100644 apps/dev/src/integration/translator/final-failure.int.test.ts create mode 100644 apps/dev/src/integration/translator/gave-up-mid-run.int.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/isLastAttempt.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/retryLimitOf.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/isLastAttempt.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedIfGaveUp.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/retryLimitOf.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/thrownByTheHandler.ts 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/completed-handle.int.test.ts b/apps/dev/src/integration/translator/completed-handle.int.test.ts new file mode 100644 index 000000000..a17fdeea6 --- /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 Job = { target_lng: string; job_id: 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: { jobs?: Job[] } }).data.jobs ?? []; + + 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)?.job_id); + } + }); +}); 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/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..29ed9f82e 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 @@ -7,7 +7,9 @@ import type { ProvenanceServiceFactory } from "../../modules/provenance/index.js import { LifecycleNotifier, taskFromHandlerInput, + taskFromStored, withQueuedNotification, + needsDecoration, } from "../../modules/lifecycle/index.js"; import type { TranslationLifecycleCallbacks } from "../../modules/lifecycle/index.js"; import type { @@ -73,18 +75,22 @@ export function wireTranslateRunner({ scope ); } catch (error) { - await notifier.failed(task, error); + if (!runner.reportsFinalFailure) await notifier.failed(task, error); throw error; // rethrow so the runner marks the job failed } await notifier.completed(task); }, collections, + reportFinalFailure: async (payload, owed, error) => { + const notifier = new LifecycleNotifier(lifecycle, payload.logger); + for (const stopped of owed) await notifier.failed(taskFromStored(stopped), error); + }, }; const taskRunnerFactory: TaskRunnerFactory = { create: (payload) => { const taskRunner = runner.create(payload, runnerContext.handler); - if (!lifecycle.onQueued) return taskRunner; + if (!needsDecoration(lifecycle)) return taskRunner; return withQueuedNotification(taskRunner, new LifecycleNotifier(lifecycle, payload.logger)); }, }; diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.test.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.test.ts new file mode 100644 index 000000000..53c950b6e --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import type { Task, TaskInput } from "../task-runner/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/lifecycle/stillOwed.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.ts new file mode 100644 index 000000000..779a1f912 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.ts @@ -0,0 +1,10 @@ +import type { Task } from "../task-runner/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/lifecycle/taskMapping.test.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.test.ts index 1affeec2c..c5fde1251 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,7 +1,8 @@ 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"; +import type { Task } from "../task-runner/types.js"; +import { taskFromInput, taskFromHandlerInput, taskFromStored } 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 @@ -46,4 +47,52 @@ describe("taskFromHandlerInput", () => { strategy: "overwrite", }); }); + + it("carries the handle when the execution knows one", () => { + const input: TaskHandlerInput = { + collection: "pages", + collectionId: "doc-2", + sourceLng: "en", + targetLng: "fr", + strategy: "overwrite", + publishOnTranslation: false, + handle: "run-9", + }; + expect(taskFromHandlerInput(input)).toEqual({ + collection: "pages", + id: "doc-2", + sourceLng: "en", + targetLng: "fr", + strategy: "overwrite", + handle: "run-9", + }); + }); +}); + +describe("taskFromStored", () => { + it("maps every public field, names the run as the handle, and omits publishOnTranslation", () => { + const stored: Task = { + id: "run-9", + status: "pending", + input: { + collectionSlug: "posts", + collectionId: "doc-3", + sourceLng: "en", + targetLng: "es", + strategy: "skip_existing", + publishOnTranslation: true, + }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + cancelled: false, + }; + expect(taskFromStored(stored)).toEqual({ + collection: "posts", + id: "doc-3", + sourceLng: "en", + targetLng: "es", + strategy: "skip_existing", + handle: "run-9", + }); + }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts index 91990fda0..33c194abd 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts @@ -1,4 +1,4 @@ -import type { TaskInput } from "../task-runner/types.js"; +import type { Task, TaskInput } from "../task-runner/types.js"; import type { TaskHandlerInput } from "../task-runner/TaskRunnerProvider.interface.js"; import type { TranslationTask } from "./types.js"; @@ -18,4 +18,14 @@ export const taskFromHandlerInput = (input: TaskHandlerInput): TranslationTask = sourceLng: input.sourceLng, targetLng: input.targetLng, strategy: input.strategy, + ...(input.handle === undefined ? {} : { handle: input.handle }), +}); + +export const taskFromStored = (task: Task): TranslationTask => ({ + collection: task.input.collectionSlug, + id: task.input.collectionId, + sourceLng: task.input.sourceLng, + targetLng: task.input.targetLng, + strategy: task.input.strategy, + handle: task.id, }); 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..8060a1115 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,14 @@ 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. + * + * Absent on `onQueued`, which fires before the run exists — take it from the enqueue answer there. + * + * @since 0.16.0 + */ + handle?: string; }; /** @@ -25,9 +33,9 @@ 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 */ @@ -41,6 +49,20 @@ export type TranslationLifecycleCallbacks = { 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. + * + * Never fires for a runner without `findByIds`. + * + * @since 0.16.0 + */ + onCancelled?: (task: TranslationTask) => void | 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..3a0b190eb 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 { ID, Task } from "./types.js"; import type { TranslationStrategyName } from "../../../core/translation-pipeline/strategies/index.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; @@ -14,6 +14,13 @@ export type TaskHandlerInput = { targetLng: string; strategy: TranslationStrategyName; publishOnTranslation: boolean; + /** + * The run this execution belongs to, as `enqueue` answered. Absent when the runner does not name + * its work. + * + * @since 0.16.0 + */ + handle?: string; }; /** @@ -31,6 +38,13 @@ export type TaskHandler = ( export type TaskRunnerContext = { handler: TaskHandler; collections: CollectionSlug[]; + /** + * Call once when a run has stopped for good, with every locale it still owed. A runner that calls + * it declares {@link TaskRunnerProvider.reportsFinalFailure}. + * + * @since 0.16.0 + */ + reportFinalFailure?: (payload: Payload, owed: Task[], error: unknown) => Promise; }; /** @@ -66,6 +80,18 @@ export interface TaskRunnerProvider { */ create(payload: Payload, handler: TaskHandler): TaskRunner; + /** + * True when this runner tells the caller about a final failure itself, through + * {@link TaskRunnerContext.reportFinalFailure}. + * + * A runner that retries must set it: a throw out of the handler may be followed by another + * attempt, so reporting at that moment would call a transient failure final. A runner that does + * not retry leaves it unset, and a throw is reported where it happens. + * + * @since 0.16.0 + */ + reportsFinalFailure?: boolean; + /** * Configures the runner and returns a Payload config modifier. * The modifier adds necessary tasks, jobs, queues to Payload config. 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..314798a85 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 @@ -9,7 +9,11 @@ import type { StoredWorkflowInput, } from "./types.js"; import { PayloadJobsTaskRunner } from "./PayloadJobsTaskRunner.js"; +import { handleOf } from "./handleOf.js"; import { readCollectionRef } from "./readCollectionRef.js"; +import { owedIfGaveUp } from "./owedIfGaveUp.js"; +import { retryLimitOf } from "./retryLimitOf.js"; +import { recallThrown, rememberThrown, theHandlerThrew } from "./thrownByTheHandler.js"; import type { TaskRunnerContext, TaskRunnerProvider } from "../TaskRunnerProvider.interface.js"; import type { Requester } from "../../../shared/payload/RequestScope.shapes.js"; import { asRequester } from "../../../shared/payload/RequestScope.shapes.js"; @@ -84,9 +88,12 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { return new PayloadJobsTaskRunner(payload, this.config); } + readonly reportsFinalFailure = true; + 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, reportFinalFailure } = context; return (config) => { const inputSchema: Field[] = [ @@ -148,6 +155,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 +169,25 @@ 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) } - ); + const handle = handleOf(args.job); + 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, + ...(handle === null ? {} : { handle }), + }, + { requester: requesterOf(args.input) } + ); + } catch (error) { + if (args.job) rememberThrown(args.job, error); + throw error; + } return { output: {} }; }, }; @@ -189,13 +204,26 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { }, } : {}), - handler: async ({ job, tasks }) => { + handler: async ({ job, req, tasks }) => { const runLocale = (tasks as Record)[taskName]; 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 } }); + try { + await runLocale(target, { input: { ...shared, target_lng: target } }); + } 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 = + reportFinalFailure && theHandlerThrew(job) + ? owedIfGaveUp(job, taskName, target, retryLimit) + : []; + if (owed.length > 0) { + await reportFinalFailure?.(req.payload, owed, recallThrown(job, error)); + } + throw error; + } } }, }; 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__/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/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/owedIfGaveUp.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedIfGaveUp.ts new file mode 100644 index 000000000..68cdedcb4 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedIfGaveUp.ts @@ -0,0 +1,28 @@ +import { stillOwed } from "../../lifecycle/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/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/thrownByTheHandler.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/thrownByTheHandler.ts new file mode 100644 index 000000000..72b46268a --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/thrownByTheHandler.ts @@ -0,0 +1,19 @@ +/** + * 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 object — 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 the task handler and the loop. + */ +const byRun = new WeakMap(); + +export const rememberThrown = (run: object, error: unknown): void => { + byRun.set(run, error); +}; + +export const theHandlerThrew = (run: object): boolean => byRun.has(run); + +export const recallThrown = (run: object, fallback: unknown): unknown => + byRun.has(run) ? byRun.get(run) : fallback; From fff4668248919fd3540cb131db1117ea6e8e4ba3 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Thu, 8 Oct 2026 12:22:33 +0200 Subject: [PATCH 03/10] feat(translator): tell the host what a cancellation stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cancelled run simply stopped existing. The plugin cancels the Payload job and then deletes its row, so a host tracking per-document state had to infer that the work was no longer happening. `onCancelled` now fires once per target locale the run still owed, before the row is deleted, so a host can still read it. A locale the run had already translated is not announced — it was not cancelled. A locale already in flight is announced and still finishes: cancelling does not stop work already running, and the callback contract says so rather than pretending otherwise. The cancel route holds only handles, so naming the locales needs a read back: a new **optional** `findByIds` on `TaskRunner`. A runner that omits it keeps working and never fires the callback — the synchronous runner has nothing to cancel, so it omits it. A failed read is logged and never costs the caller their cancellation, and a runner that cannot resolve handles is not asked, so silence stays distinguishable from a read that failed quietly. The wrapper is now applied for any callback it fires. A host registering only `onCancelled` used to be handed an undecorated runner and heard nothing. Both runners are now held to one contract by one call. `TaskRunner.interface.ts` states the obligations every implementation owes — what an empty request answers, that a handle is a non-empty string, that only requested locales come back, that an unknown handle is an answer rather than a failure — and `TaskRunner.invariants.ts` asserts them against any runner. It was written against the contract alone, run red against a stub first, and it found a real divergence: the synchronous runner answered twice, and translated twice, for a locale a request named twice. --- .../cancel-announcement.int.test.ts | 79 ++++ .../cancel-while-running.int.test.ts | 72 +++ .../task-runner-contract.int.test.ts | 19 + ...26-10-07-TaskRunner-invariants-red-run.txt | 339 ++++++++++++++ .../modules/lifecycle/LifecycleNotifier.ts | 16 + .../src/server/modules/lifecycle/index.ts | 4 +- .../modules/lifecycle/needsDecoration.test.ts | 29 ++ .../modules/lifecycle/needsDecoration.ts | 7 + .../lifecycle/withQueuedNotification.test.ts | 433 +++++++++++++++--- .../lifecycle/withQueuedNotification.ts | 29 +- .../__tests__/TaskRunner.invariants.ts | 334 ++++++++++++++ .../__tests__/SyncTaskRunner.contract.test.ts | 17 + 12 files changed, 1318 insertions(+), 60 deletions(-) create mode 100644 apps/dev/src/integration/translator/cancel-announcement.int.test.ts create mode 100644 apps/dev/src/integration/translator/cancel-while-running.int.test.ts create mode 100644 apps/dev/src/integration/translator/task-runner-contract.int.test.ts create mode 100644 packages/payload-plugin-translator/docs/plans/artifacts/2026-10-07-TaskRunner-invariants-red-run.txt create mode 100644 packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts 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..dd6d57bf3 --- /dev/null +++ b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts @@ -0,0 +1,79 @@ +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 Job = { target_lng: string; job_id: 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: { jobs?: Job[] } }).data.jobs ?? [])[0]?.job_id; + 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..8c3850fdb --- /dev/null +++ b/apps/dev/src/integration/translator/cancel-while-running.int.test.ts @@ -0,0 +1,72 @@ +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 Job = { target_lng: string; job_id: 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: { jobs?: Job[] } }).data.jobs ?? [])[0]?.job_id; + + // 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/task-runner-contract.int.test.ts b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts new file mode 100644 index 000000000..14a63cc44 --- /dev/null +++ b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts @@ -0,0 +1,19 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll } 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", () => + createPayloadJobsRunner({ autoRun: false }).create(ctx.payload, async () => undefined) +); 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/src/server/modules/lifecycle/LifecycleNotifier.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.ts index 3c2bd960d..e899e9b10 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,22 @@ export class LifecycleNotifier { await this.safe("lifecycle.onFailed", () => callback(task, error)); } + async cancelling(read: () => Promise): Promise { + try { + for (const task of await read()) await this.cancelled(task); + } catch (error) { + this.logger.error({ + err: error, + msg: "translator: could not read what a cancellation is stopping", + }); + } + } + + private 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..9485ed5d6 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/index.ts @@ -2,5 +2,7 @@ // provenance opt-in, no schema/migration). export { LifecycleNotifier } from "./LifecycleNotifier.js"; export { withQueuedNotification } from "./withQueuedNotification.js"; -export { taskFromHandlerInput, taskFromInput } from "./taskMapping.js"; +export { needsDecoration } from "./needsDecoration.js"; +export { stillOwed } from "./stillOwed.js"; +export { taskFromHandlerInput, taskFromInput, taskFromStored } from "./taskMapping.js"; export type { TranslationLifecycleCallbacks, TranslationTask } from "./types.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.test.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.test.ts new file mode 100644 index 000000000..071f615e2 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { needsDecoration } from "./needsDecoration.js"; +import type { TranslationLifecycleCallbacks } from "./types.js"; + +const noop = (): void => undefined; + +describe("needsDecoration", () => { + it.each<[string, TranslationLifecycleCallbacks]>([ + ["onQueued", { onQueued: noop }], + ["onCancelled", { onCancelled: noop }], + ["both of them", { onQueued: noop, onCancelled: noop }], + ["one of them beside a handler-raised one", { onCancelled: noop, onCompleted: noop }], + ])("wraps for a host registering %s", (_label, callbacks) => { + expect(needsDecoration(callbacks)).toBe(true); + }); + + it.each<[string, TranslationLifecycleCallbacks]>([ + ["nothing at all", {}], + ["only onCompleted", { onCompleted: noop }], + ["only onFailed", { onFailed: noop }], + ["both handler-raised ones", { onCompleted: noop, onFailed: noop }], + ])("does not wrap for a host registering %s", (_label, callbacks) => { + expect( + needsDecoration(callbacks), + "the task handler reports these whether or not the runner is wrapped" + ).toBe(false); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.ts new file mode 100644 index 000000000..32feb56c7 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.ts @@ -0,0 +1,7 @@ +import type { TranslationLifecycleCallbacks } from "./types.js"; + +/** `onCompleted` and `onFailed` come from the task handler, wrapped or not. */ +export type NeedsDecoration = (callbacks: TranslationLifecycleCallbacks) => boolean; + +export const needsDecoration: NeedsDecoration = (callbacks) => + Boolean(callbacks.onQueued ?? callbacks.onCancelled); 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 index 4a88bc7a9..4c669eb32 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.test.ts @@ -1,82 +1,411 @@ -import { describe, it, expect, vi } from "vitest"; import type { CollectionSlug } from "payload"; -import { withQueuedNotification } from "./withQueuedNotification.js"; +import { describe, expect, it, vi } from "vitest"; +import type { TaskFilter, TaskRunner } from "../task-runner/TaskRunner.interface.js"; +import type { + EnqueueAssignment, + RunResult, + Task, + TaskInput, + TaskStatus, +} from "../task-runner/types.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([]), -}); +import type { TranslationLifecycleCallbacks, TranslationTask } from "./types.js"; +import { withQueuedNotification } from "./withQueuedNotification.js"; + +const makeLogger = () => ({ error: vi.fn() }); -const input = (id: string): TaskInput => ({ - collectionSlug: "posts" as CollectionSlug, - collectionId: id, +/** A notifier whose logger the test can read: a swallowed failure surfaces nowhere else. */ +const notifierFor = ( + callbacks: TranslationLifecycleCallbacks, + logger: { error: (obj: unknown) => void } = makeLogger() +) => new LifecycleNotifier(callbacks, logger); + +const taskInput = (overrides: Partial = {}): TaskInput => ({ + collectionSlug: "posts", + collectionId: "doc-1", sourceLng: "en", targetLng: "de", strategy: "overwrite", publishOnTranslation: false, + ...overrides, +}); + +const storedTask = (id: string, input: TaskInput, status: TaskStatus = "pending"): Task => ({ + id, + status, + input, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + cancelled: false, }); -describe("withQueuedNotification", () => { - it("fires queued for each task (mapped to TranslationTask) then delegates enqueue", async () => { - const runner = makeRunner(); +/** The runner the wrapper goes around. `findByIds` is absent on purpose — it is the optional part + * of the contract. */ +const makeRunner = (parts: Partial = {}): TaskRunner => ({ + enqueue: vi.fn(async () => undefined), + cancel: vi.fn(async () => undefined), + run: vi.fn(async () => ({ success: true as const })), + findByCollection: vi.fn(async () => []), + ...parts, +}); + +describe("withQueuedNotification — enqueue", () => { + it("raises queued for each task it was given", async () => { const onQueued = vi.fn(); - const notifier = new LifecycleNotifier({ onQueued }, { error: vi.fn() }); + const tasks = [taskInput(), taskInput({ collectionId: "doc-2", targetLng: "fr" })]; - const wrapped = withQueuedNotification(runner, notifier); - await wrapped.enqueue([input("1"), input("2")]); + await withQueuedNotification(makeRunner(), notifierFor({ onQueued })).enqueue(tasks); - expect(onQueued).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ collection: "posts", id: "1", sourceLng: "en", targetLng: "de" }) + expect(onQueued).toHaveBeenCalledTimes(2); + expect(onQueued).toHaveBeenCalledWith( + expect.objectContaining({ collection: "posts", id: "doc-1", targetLng: "de" }) ); - expect(onQueued).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: "2" })); - expect(runner.enqueue).toHaveBeenCalledWith([input("1"), input("2")], undefined); + expect(onQueued).toHaveBeenCalledWith( + expect.objectContaining({ collection: "posts", id: "doc-2", targetLng: "fr" }) + ); + }); + + it("answers with the handles the wrapped runner answered", async () => { + const assignments: EnqueueAssignment[] = [ + { collectionSlug: "posts", collectionId: "doc-1", targetLng: "de", handle: "run-1" }, + ]; + const wrapped = makeRunner({ enqueue: vi.fn(async () => assignments) }); + + const answer = await withQueuedNotification(wrapped, notifierFor({})).enqueue([taskInput()]); + + expect(answer).toEqual(assignments); }); - 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"); + it("answers with nothing when the wrapped runner answered nothing", async () => { + const enqueue = vi.fn(async () => undefined); + + const answer = await withQueuedNotification(makeRunner({ enqueue }), notifierFor({})).enqueue([ + taskInput(), + ]); + + expect(enqueue).toHaveBeenCalledTimes(1); + expect(answer).toBeUndefined(); + }); + + it("asks the wrapped runner with the tasks and the scope it was given", async () => { + const enqueue = vi.fn(async () => undefined); + const tasks = [taskInput()]; + const scope = { transactionID: "tx-1" }; + + await withQueuedNotification(makeRunner({ enqueue }), notifierFor({})).enqueue(tasks, scope); + + expect(enqueue).toHaveBeenCalledWith(tasks, scope); + }); + + it("raises queued for every task before the wrapped runner is asked", async () => { + const order: string[] = []; + const onQueued = vi.fn(() => { + order.push("queued"); + }); + const enqueue = vi.fn(async () => { + order.push("enqueue"); }); - const notifier = new LifecycleNotifier( - { - onQueued: () => { - calls.push("queued"); - }, + + await withQueuedNotification(makeRunner({ enqueue }), notifierFor({ onQueued })).enqueue([ + taskInput(), + taskInput({ targetLng: "fr" }), + ]); + + expect(order).toEqual(["queued", "queued", "enqueue"]); + }); + + it("keeps queued ahead of a completed the runner reports from inside the enqueue call", async () => { + const order: string[] = []; + const notifier = notifierFor({ + onQueued: async () => { + await Promise.resolve(); + order.push("queued"); }, - { error: vi.fn() } + onCompleted: () => { + order.push("completed"); + }, + }); + const translated: TranslationTask = { + collection: "posts", + id: "doc-1", + sourceLng: "en", + targetLng: "de", + strategy: "overwrite", + handle: "run-1", + }; + const enqueue = vi.fn(async () => { + await notifier.completed(translated); + }); + + await withQueuedNotification(makeRunner({ enqueue }), notifier).enqueue([taskInput()]); + + expect(order).toEqual(["queued", "completed"]); + }); + + it("reports a queued task with no handle", async () => { + const onQueued = vi.fn(); + const assignments: EnqueueAssignment[] = [ + { collectionSlug: "posts", collectionId: "doc-1", targetLng: "de", handle: "run-1" }, + ]; + const wrapped = makeRunner({ enqueue: vi.fn(async () => assignments) }); + + await withQueuedNotification(wrapped, notifierFor({ onQueued })).enqueue([taskInput()]); + + expect(onQueued).toHaveBeenCalledTimes(1); + const queued = onQueued.mock.calls[0]?.[0] as TranslationTask; + expect(queued.handle).toBeUndefined(); + }); +}); + +describe("withQueuedNotification — cancel", () => { + it("announces, once per locale, what the cancelled runs still owed", async () => { + const onCancelled = vi.fn(); + const owed = [storedTask("t1", taskInput()), storedTask("t2", taskInput({ targetLng: "fr" }))]; + const wrapped = makeRunner({ findByIds: vi.fn(async () => owed) }); + + await withQueuedNotification(wrapped, notifierFor({ onCancelled })).cancel(["t1", "t2"]); + + expect(onCancelled).toHaveBeenCalledTimes(2); + expect(onCancelled).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "posts", + id: "doc-1", + sourceLng: "en", + targetLng: "de", + strategy: "overwrite", + }) ); + expect(onCancelled).toHaveBeenCalledWith(expect.objectContaining({ targetLng: "fr" })); + }); - await withQueuedNotification(runner, notifier).enqueue([input("1")]); + it("reads the records of the handles it was given", async () => { + const findByIds = vi.fn(async () => [storedTask("t1", taskInput())]); - expect(calls).toEqual(["queued", "enqueue"]); + await withQueuedNotification( + makeRunner({ findByIds }), + notifierFor({ onCancelled: vi.fn() }) + ).cancel(["t1", "t2"]); + + expect(findByIds).toHaveBeenCalledWith(["t1", "t2"]); }); - it("delegates cancel and run unchanged", async () => { - const runner = makeRunner(); - const wrapped = withQueuedNotification(runner, new LifecycleNotifier({}, { error: vi.fn() })); + it("announces before delegating the cancellation", async () => { + const order: string[] = []; + const cancel = vi.fn(async () => { + order.push("cancel"); + }); + const findByIds = vi.fn(async () => [storedTask("t1", taskInput())]); + const notifier = notifierFor({ + onCancelled: () => { + order.push("cancelled"); + }, + }); - await wrapped.cancel(["a"]); - await wrapped.run("b"); + await withQueuedNotification(makeRunner({ cancel, findByIds }), notifier).cancel(["t1"]); - expect(runner.cancel).toHaveBeenCalledWith(["a"]); - expect(runner.run).toHaveBeenCalledWith("b"); + expect(order).toEqual(["cancelled", "cancel"]); }); - it("hands findByCollection's filter on, normalizing the deprecated array form", async () => { - const runner = makeRunner(); - const wrapped = withQueuedNotification(runner, new LifecycleNotifier({}, { error: vi.fn() })); + it("lets an announcement finish before delegating the cancellation", async () => { + const order: string[] = []; + const cancel = vi.fn(async () => { + order.push("cancel"); + }); + const findByIds = vi.fn(async () => [storedTask("t1", taskInput())]); + // Settles on a timer, not a microtask: an announcement that is started but not awaited loses + // this race. + const notifier = notifierFor({ + onCancelled: () => + new Promise((resolve) => { + setTimeout(() => { + order.push("cancelled"); + resolve(); + }, 10); + }), + }); + + await withQueuedNotification(makeRunner({ cancel, findByIds }), notifier).cancel(["t1"]); + + expect(order).toEqual(["cancelled", "cancel"]); + }); + + it("announces the handle of the run being cancelled", async () => { + const onCancelled = vi.fn(); + const records = [ + storedTask("run-1", taskInput()), + storedTask("run-2", taskInput({ targetLng: "fr" })), + ]; + const wrapped = makeRunner({ findByIds: vi.fn(async () => records) }); + + await withQueuedNotification(wrapped, notifierFor({ onCancelled })).cancel(["run-1", "run-2"]); + + expect(onCancelled).toHaveBeenCalledWith( + expect.objectContaining({ targetLng: "de", handle: "run-1" }) + ); + expect(onCancelled).toHaveBeenCalledWith( + expect.objectContaining({ targetLng: "fr", handle: "run-2" }) + ); + }); + + it("does not announce a locale the run had already translated", async () => { + const onCancelled = vi.fn(); + const records = [ + storedTask("t1", taskInput(), "completed"), + storedTask("t2", taskInput({ targetLng: "fr" })), + ]; + const wrapped = makeRunner({ findByIds: vi.fn(async () => records) }); + + await withQueuedNotification(wrapped, notifierFor({ onCancelled })).cancel(["t1", "t2"]); + + expect(onCancelled).toHaveBeenCalledTimes(1); + expect(onCancelled).toHaveBeenCalledWith(expect.objectContaining({ targetLng: "fr" })); + }); + + it("cancels as before when the runner cannot resolve handles", async () => { + const cancel = vi.fn(async () => undefined); + + const wrapper = withQueuedNotification( + makeRunner({ cancel }), + notifierFor({ onCancelled: vi.fn() }) + ); + + await expect(wrapper.cancel(["t1", "t2"])).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledWith(["t1", "t2"]); + }); + + it("announces nothing when the runner cannot resolve handles", async () => { + const cancel = vi.fn(async () => undefined); + const onCancelled = vi.fn(); + + await withQueuedNotification(makeRunner({ cancel }), notifierFor({ onCancelled })).cancel([ + "t1", + ]); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(onCancelled).not.toHaveBeenCalled(); + }); + + it("does not ask a runner that cannot resolve handles to resolve them", async () => { + const cancel = vi.fn(async () => undefined); + const logger = makeLogger(); + + await withQueuedNotification( + makeRunner({ cancel }), + notifierFor({ onCancelled: vi.fn() }, logger) + ).cancel(["t1"]); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("swallows a read that fails, and still cancels", async () => { + const cancel = vi.fn(async () => undefined); + const findByIds = vi.fn(() => Promise.reject(new Error("database unreachable"))); + const wrapper = withQueuedNotification( + makeRunner({ cancel, findByIds }), + notifierFor({ onCancelled: vi.fn() }) + ); + + await expect(wrapper.cancel(["t1"])).resolves.toBeUndefined(); + expect(findByIds).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith(["t1"]); + }); + + it("records a read that fails rather than passing it off as nothing to announce", async () => { + const failure = new Error("database unreachable"); + const findByIds = vi.fn(() => Promise.reject(failure)); + const logger = makeLogger(); + + await withQueuedNotification( + makeRunner({ findByIds }), + notifierFor({ onCancelled: vi.fn() }, logger) + ).cancel(["t1"]); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ err: failure })); + }); + + it("still cancels when the host's onCancelled throws", async () => { + const cancel = vi.fn(async () => undefined); + const findByIds = vi.fn(async () => [storedTask("t1", taskInput())]); + const notifier = notifierFor({ + onCancelled: () => { + throw new Error("host blew up"); + }, + }); + const wrapper = withQueuedNotification(makeRunner({ cancel, findByIds }), notifier); + + await expect(wrapper.cancel(["t1"])).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledWith(["t1"]); + }); +}); + +describe("withQueuedNotification — the methods that only pass through", () => { + it("answers run exactly as the wrapped runner", async () => { + const notFound: RunResult = { success: false, error: "not_found" }; + const run = vi.fn(async (taskId: string) => + taskId === "t1" ? notFound : { success: true as const } + ); + + const answer = await withQueuedNotification(makeRunner({ run }), notifierFor({})).run("t1"); + + expect(answer).toEqual(notFound); + }); + + it("answers findByCollection exactly as the wrapped runner", async () => { + const pending = [storedTask("t1", taskInput())]; + const findByCollection = vi.fn( + async (_slug: CollectionSlug, filter?: TaskFilter | Array) => + !Array.isArray(filter) && filter?.excludeCompleted ? pending : [] + ); + + const answer = await withQueuedNotification( + makeRunner({ findByCollection }), + notifierFor({}) + ).findByCollection("posts", { excludeCompleted: true }); + + expect(answer).toEqual(pending); + }); + + it("keeps the optional findByIds available to anything downstream", async () => { + const owed = [storedTask("t1", taskInput())]; + const findByIds = vi.fn(async (taskIds: string[]) => (taskIds[0] === "t1" ? owed : [])); + const wrapper = withQueuedNotification(makeRunner({ findByIds }), notifierFor({})); + + expect(wrapper.findByIds).toBeDefined(); + await expect(wrapper.findByIds?.(["t1"])).resolves.toEqual(owed); + }); + + it("leaves findByIds absent when the wrapped runner cannot resolve handles", () => { + const wrapper = withQueuedNotification(makeRunner(), notifierFor({})); + + expect(wrapper.findByIds).toBeUndefined(); + }); + + it("tells the host nothing for the methods that only pass through", async () => { + const callbacks = { + onQueued: vi.fn(), + onCompleted: vi.fn(), + onFailed: vi.fn(), + onCancelled: vi.fn(), + }; + const run = vi.fn(async () => ({ success: true as const })); + const findByCollection = vi.fn(async () => []); + const findByIds = vi.fn(async () => []); + const wrapper = withQueuedNotification( + makeRunner({ run, findByCollection, findByIds }), + notifierFor(callbacks) + ); - await wrapped.findByCollection("posts" as CollectionSlug, ["c"]); - expect(runner.findByCollection).toHaveBeenCalledWith("posts", { documentIds: ["c"] }); + await wrapper.run("t1"); + await wrapper.findByCollection("posts"); + await wrapper.findByIds?.(["t1"]); - await wrapped.findByCollection("posts" as CollectionSlug, { excludeCompleted: true }); - expect(runner.findByCollection).toHaveBeenLastCalledWith("posts", { excludeCompleted: true }); + expect(run).toHaveBeenCalledTimes(1); + expect(findByCollection).toHaveBeenCalledTimes(1); + expect(findByIds).toHaveBeenCalledTimes(1); + for (const callback of Object.values(callbacks)) { + expect(callback).not.toHaveBeenCalled(); + } }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts index a52457866..aa0379f92 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts @@ -1,13 +1,24 @@ 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"; +import type { TranslationTask } from "./types.js"; +import { stillOwed } from "./stillOwed.js"; +import { taskFromInput, taskFromStored } from "./taskMapping.js"; + +async function stopping(runner: TaskRunner, taskIds: string[]): Promise { + if (!runner.findByIds || taskIds.length === 0) return []; + return stillOwed(await runner.findByIds(taskIds)).map(taskFromStored); +} /** - * 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. + * Wrap a {@link TaskRunner} so it reports to the host; every method otherwise delegates unchanged. + * + * `queued` is raised before the wrapped runner is asked: a runner may translate inline and report + * `completed` from inside that call. + * + * `cancel` awaits its announcements before delegating — the records are gone afterwards. + * + * @since 0.16.0 `cancel` announces; `enqueue` answers with the handles. */ export function withQueuedNotification( runner: TaskRunner, @@ -16,13 +27,17 @@ export function withQueuedNotification( return { async enqueue(tasks, scope) { await Promise.all(tasks.map((task) => notifier.queued(taskFromInput(task)))); - await runner.enqueue(tasks, scope); + return runner.enqueue(tasks, scope); + }, + async cancel(taskIds) { + await notifier.cancelling(() => stopping(runner, taskIds)); + return runner.cancel(taskIds); }, - 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)), + ...(runner.findByIds ? { findByIds: (ids: string[]) => runner.findByIds!(ids) } : {}), }; } 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..049253373 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts @@ -0,0 +1,334 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { TaskRunner } from "../TaskRunner.interface.js"; +import type { EnqueueAssignment, TaskInput } from "../types.js"; + +const SLUG = "posts"; +const UNKNOWN_HANDLE = "no-runner-ever-issued-this-handle"; + +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 localeOf = (handle: string, targetLng: string): string => `${handle}/${targetLng}`; + +const settle = (promise: Promise): Promise<"resolved" | "rejected"> => + promise.then(() => "resolved" as const).catch(() => "rejected" as const); + +/** + * The obligations `TaskRunner` owes its callers, asserted against one implementation. + * + * Every check traces to a sentence of `TaskRunner.interface.ts` or `types.ts`; the nested + * `describe` names quote the obligation they belong to. Checks that need the optional + * `findByIds`, or a handle from a runner that answers `enqueue` with nothing, skip themselves + * instead of failing. + * + * @param name - how the implementation under test is reported + * @param make - builds a ready-to-use runner; called fresh for every check + */ +export function assertTaskRunnerContract( + name: string, + make: () => TaskRunner | Promise +): void { + describe(name, () => { + let runner: TaskRunner; + // A real database keeps what earlier checks queued. + let checkNumber = 0; + const documentForThisCheck = (n: number): string => `doc-${checkNumber}-${n}`; + + beforeEach(async () => { + checkNumber += 1; + runner = await make(); + }); + + const enqueued = async (tasks: TaskInput[]): Promise => { + const answer = await runner.enqueue(tasks); + return Array.isArray(answer) ? answer : undefined; + }; + + 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("answers an empty findByIds", async (ctx) => { + const findByIds = runner.findByIds?.bind(runner); + if (!findByIds) { + ctx.skip(); + return; + } + + const answer: unknown = await findByIds([]); + + expect(answer ?? [], "findByIds([]) answers [] or undefined").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([ + input(documentForThisCheck(1), "de"), + input(documentForThisCheck(1), "fr"), + ]); + 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; + } + const handles = [...new Set(answer.map((assignment) => assignment.handle))]; + + expect( + await settle(runner.cancel(handles)), + "cancel takes the runner's own handle back unchanged" + ).toBe("resolved"); + }); + }); + + describe("only what was asked for is answered", () => { + const request = [ + input(documentForThisCheck(1), "de"), + input(documentForThisCheck(1), "fr"), + input(documentForThisCheck(2), "de"), + ]; + + it("answers only locales the request listed", async (ctx) => { + 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(request); + 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 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("an unknown handle is an answer, not a failure", () => { + it("answers run on an unknown handle with not_found", async () => { + expect(await runner.run(UNKNOWN_HANDLE)).toEqual({ success: false, error: "not_found" }); + }); + + it("resolves cancel on an unknown handle", async () => { + expect( + await settle(runner.cancel([UNKNOWN_HANDLE])), + "cancel on an unknown handle resolves rather than rejecting" + ).toBe("resolved"); + }); + + it("cancels nothing when the handle is unknown", async (ctx) => { + const findByIds = runner.findByIds?.bind(runner); + const answer = await enqueued([input(documentForThisCheck(1), "de")]); + if (!findByIds || !answer) { + ctx.skip(); + return; + } + const handles = [...new Set(answer.map((assignment) => assignment.handle))]; + + await runner.cancel([UNKNOWN_HANDLE]); + const cancelled = (await findByIds(handles)).filter((task) => task.cancelled); + + expect(cancelled, "cancel on an unknown handle changes nothing").toEqual([]); + }); + }); + + describe("findByIds resolves handles to the tasks they stand for", () => { + it("answers only tasks for the handles asked about", async (ctx) => { + const findByIds = runner.findByIds?.bind(runner); + const answer = await enqueued([input(documentForThisCheck(1), "de")]); + if (!findByIds || !answer) { + ctx.skip(); + return; + } + const handles = new Set(answer.map((assignment) => assignment.handle)); + + const foreign = (await findByIds([...handles])).filter((task) => !handles.has(task.id)); + + expect(foreign, "every task stands for one of the handles asked about").toEqual([]); + }); + + it("announces each target locale of a handle separately", async (ctx) => { + const findByIds = runner.findByIds?.bind(runner); + const answer = await enqueued([ + input(documentForThisCheck(1), "de"), + input(documentForThisCheck(1), "fr"), + ]); + if (!findByIds || !answer) { + ctx.skip(); + return; + } + const handles = [...new Set(answer.map((assignment) => assignment.handle))]; + const tasks = await findByIds(handles); + const announced = new Set(tasks.map((task) => localeOf(task.id, task.input.targetLng))); + + const missing = answer + .map((assignment) => localeOf(assignment.handle, assignment.targetLng)) + .filter((locale) => !announced.has(locale)); + + expect(missing, "one Task per handle and target locale").toEqual([]); + }); + + it("announces a target locale once per handle", async (ctx) => { + const findByIds = runner.findByIds?.bind(runner); + const answer = await enqueued([ + input(documentForThisCheck(1), "de"), + input(documentForThisCheck(1), "fr"), + ]); + if (!findByIds || !answer) { + ctx.skip(); + return; + } + const handles = [...new Set(answer.map((assignment) => assignment.handle))]; + + const tasks = await findByIds(handles); + const locales = tasks.map((task) => localeOf(task.id, task.input.targetLng)); + const announcedOnce = new Set(locales).size; + + expect(announcedOnce, "one Task per handle and target locale").toBe(locales.length); + }); + }); + + 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 () => { + await runner.enqueue([ + input(documentForThisCheck(1), "de"), + input(documentForThisCheck(2), "de"), + ]); + + const tasks = await runner.findByCollection(SLUG, { documentIds: ["doc-1"] }); + + expect( + tasks.filter((task) => task.input.collectionId !== "doc-1"), + "documentIds keeps only tasks for these documents" + ).toEqual([]); + }); + + it("keeps only the documents the deprecated array names", async () => { + await runner.enqueue([ + input(documentForThisCheck(1), "de"), + input(documentForThisCheck(2), "de"), + ]); + + const tasks = await runner.findByCollection(SLUG, ["doc-1"]); + + expect( + tasks.filter((task) => task.input.collectionId !== "doc-1"), + "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/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..45d731acc --- /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", + () => + new SyncTaskRunner( + {} as Payload, + vi.fn().mockResolvedValue(undefined), + new LazyMap({ isRemovable: () => false, getTimestamp: () => 0 }) + ) +); From 44d5e3cd2537a0c339675c76378928f474a01ae1 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Thu, 8 Oct 2026 12:22:42 +0200 Subject: [PATCH 04/10] feat(translator): name the document and locale in the collection status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /translate/collection/:slug` answered with a run handle and a status, and a caller could resolve neither back to what was being translated. A run covers a document's whole locale list, so the handle repeats across entries and cannot identify one on its own — the queue view an admin dashboard wanted could not be rendered from it. Each entry now names the document and the target locale beside the handle. The runner's normalized task already carried both; only the response mapping discarded them. --- .../get-collection-status/handler.test.ts | 27 ++++++++++++------- .../features/get-collection-status/handler.ts | 7 ++++- .../features/get-collection-status/model.ts | 5 +++- 3 files changed, 27 insertions(+), 12 deletions(-) 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; }; /** From ec1c08bb2a4613b675f0a70dae95f0d5d4115064 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Thu, 8 Oct 2026 14:26:35 +0200 Subject: [PATCH 05/10] refactor(translator): say what the enqueue answer and the contract actually mean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the owner caught in review, both about names and promises rather than behaviour. **The enqueue answer no longer renames a handle into a job id.** It answered with `{ ..., job_id }` under a `jobs` key, so the HTTP handler was translating the runner's opaque handle into the vocabulary of one particular runner — Payload jobs — in a response that is meant to be runner-neutral. The synchronous runner has no jobs at all. The answer now carries `handle` under `assignments`, and the mapping left in the handler is a pure conversion to the snake_case this surface already answers in. Putting that mapping inside `TaskRunner.enqueue` instead would push the knowledge the wrong way: the contract is implemented outside this package, and a third-party runner would have to produce HTTP field names. The field is new in this change and unreleased, so renaming it costs nothing. **The contract now promises only what every store can keep.** It said an unknown handle resolves rather than rejecting. That is false on PostgreSQL and MongoDB: the job id is an integer there, so a handle no runner could have issued fails inside the query instead of finding nothing. The conformance suite found it — those two checks failed on both databases and passed on SQLite, which is lenient. The obligation now reaches handles **this runner issued**, and the suite exercises a handle it obtained for real and then had cancelled: unknown by then, and shaped as that runner shapes handles. The limit is written down beside the obligations, including its consequence — `DELETE /translate/cancel` answers with a server error for a value the store cannot parse. That is left unfixed on purpose: every way to fix it costs something decided deliberately elsewhere — adapter-specific error codes, an unbounded scan the project already removed once, or parsing a handle the contract says is never parsed. --- .../cancel-announcement.int.test.ts | 5 +- .../cancel-while-running.int.test.ts | 5 +- .../translator/completed-handle.int.test.ts | 6 +-- .../translator/enqueue-handles.int.test.ts | 35 ++++++++------ .../retranslate-after-giving-up.int.test.ts | 10 ++-- .../2026-10-06-lifecycle-run-context.task.md | 2 +- .../features/enqueue-translation/handler.ts | 4 +- .../features/enqueue-translation/model.ts | 10 ++-- .../task-runner/TaskRunner.interface.ts | 5 ++ .../__tests__/TaskRunner.invariants.ts | 46 ++++++++++++++----- 10 files changed, 82 insertions(+), 46 deletions(-) diff --git a/apps/dev/src/integration/translator/cancel-announcement.int.test.ts b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts index dd6d57bf3..03fc1f45b 100644 --- a/apps/dev/src/integration/translator/cancel-announcement.int.test.ts +++ b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts @@ -6,7 +6,7 @@ import type { TestPayload } from "./bootTestPayload"; import { callEndpoint } from "./callEndpoint"; type Seen = { targetLng: string; handle?: string }; -type Job = { target_lng: string; job_id: string }; +type Assignment = { target_lng: string; handle: string }; const ATTEMPTS_SO_THE_RUN_STILL_HAS_A_TRY_LEFT = 3; @@ -58,7 +58,8 @@ describe("cancelling announces what it stopped, to a host that registered nothin publish_on_translation: false, }, }); - const handle = ((res.data as { data: { jobs?: Job[] } }).data.jobs ?? [])[0]?.job_id; + 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] } }); 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 index 8c3850fdb..fa5d4abe0 100644 --- a/apps/dev/src/integration/translator/cancel-while-running.int.test.ts +++ b/apps/dev/src/integration/translator/cancel-while-running.int.test.ts @@ -5,7 +5,7 @@ import { bootTestPayload } from "./bootTestPayload"; import type { TestPayload } from "./bootTestPayload"; import { callEndpoint } from "./callEndpoint"; -type Job = { target_lng: string; job_id: string }; +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 @@ -58,7 +58,8 @@ describe.skipIf(!SQLITE)("cancelling a run that is already translating", () => { publish_on_translation: false, }, }); - handle = ((res.data as { data: { jobs?: Job[] } }).data.jobs ?? [])[0]?.job_id; + 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); diff --git a/apps/dev/src/integration/translator/completed-handle.int.test.ts b/apps/dev/src/integration/translator/completed-handle.int.test.ts index a17fdeea6..7d0f253be 100644 --- a/apps/dev/src/integration/translator/completed-handle.int.test.ts +++ b/apps/dev/src/integration/translator/completed-handle.int.test.ts @@ -6,7 +6,7 @@ import type { TestPayload } from "./bootTestPayload"; import { callEndpoint } from "./callEndpoint"; type Seen = { targetLng: string; handle?: string }; -type Job = { target_lng: string; job_id: string }; +type Assignment = { target_lng: string; handle: string }; const onCompleted = vi.fn<(task: Seen) => void>(); @@ -39,7 +39,7 @@ describe("the handle the enqueue answer named is the handle onCompleted carries" publish_on_translation: false, }, }); - const promised = (res.data as { data: { jobs?: Job[] } }).data.jobs ?? []; + const promised = (res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? []; await ctx.payload.jobs.run({ queue: "translations" }); @@ -49,7 +49,7 @@ describe("the handle the enqueue answer named is the handle onCompleted carries" 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)?.job_id); + ).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 index 0b43cf3ec..dba0f82d6 100644 --- a/apps/dev/src/integration/translator/enqueue-handles.int.test.ts +++ b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts @@ -7,7 +7,12 @@ import { callEndpoint } from "./callEndpoint"; let ctx: TestPayload; -type Job = { collection_slug: string; collection_id: string; target_lng: string; job_id: string }; +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({ @@ -18,7 +23,7 @@ const create = async (title: string): Promise => { return String(made.id); }; -const enqueue = async (ids: string[], locales: string[]): Promise => { +const enqueue = async (ids: string[], locales: string[]): Promise => { const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { body: { source_lng: "en", @@ -29,7 +34,7 @@ const enqueue = async (ids: string[], locales: string[]): Promise => { publish_on_translation: false, }, }); - return (res.data as { data: { jobs?: Job[] } }).data.jobs ?? []; + return (res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? []; }; describe("the enqueue answer names the run that will translate each locale", () => { @@ -43,26 +48,26 @@ describe("the enqueue answer names the run that will translate each locale", () it("answers once per requested locale, naming a run that really exists", async () => { const id = await create("One document"); - const jobs = await enqueue([id], ["de", "fr"]); + const assignments = await enqueue([id], ["de", "fr"]); - expect(jobs.map((j) => j.target_lng).sort()).toEqual(["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: jobs.map((j) => j.job_id) } } as never, + 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(jobs.map((j) => j.job_id)).size + 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 jobs = await enqueue([id], ["de", "fr"]); + const assignments = await enqueue([id], ["de", "fr"]); expect( - new Set(jobs.map((j) => j.job_id)), + new Set(assignments.map((j) => j.handle)), "one run covers a document's locale list" ).toHaveLength(1); }); @@ -71,12 +76,12 @@ describe("the enqueue answer names the run that will translate each locale", () const first = await create("First"); const second = await create("Second"); - const jobs = await enqueue([first, second], ["de", "fr"]); + const assignments = await enqueue([first, second], ["de", "fr"]); - expect(jobs, "two documents, two locales each").toHaveLength(4); - expect(new Set(jobs.map((j) => j.job_id))).toHaveLength(2); + expect(assignments, "two documents, two locales each").toHaveLength(4); + expect(new Set(assignments.map((j) => j.handle))).toHaveLength(2); expect( - new Set(jobs.filter((j) => j.collection_id === first).map((j) => j.job_id)) + new Set(assignments.filter((j) => j.collection_id === first).map((j) => j.handle)) ).toHaveLength(1); }); @@ -91,8 +96,8 @@ describe("the enqueue answer names the run that will translate each locale", () "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")?.job_id, + second.find((j) => j.target_lng === "de")?.handle, "and it is the run that already had it" - ).toBe(first[0]?.job_id); + ).toBe(first[0]?.handle); }); }); 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 index 3d2a1fd71..65242493c 100644 --- 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 @@ -8,12 +8,12 @@ import { callEndpoint } from "./callEndpoint"; const RETRIES = 1; const PASSES_UNTIL_THE_RUN_GIVES_UP = RETRIES + 1; -type Job = { target_lng: string; job_id: string }; +type Assignment = { target_lng: string; handle: string }; let ctx: TestPayload; let providerIsDown = true; -const enqueue = async (id: string, locales: string[]): Promise => { +const enqueue = async (id: string, locales: string[]): Promise => { const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { body: { source_lng: "en", @@ -24,7 +24,7 @@ const enqueue = async (id: string, locales: string[]): Promise => { publish_on_translation: false, }, }); - return (res.data as { data: { jobs?: Job[] } }).data.jobs ?? []; + return (res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? []; }; describe("asking again after a run has given up", () => { @@ -61,9 +61,9 @@ describe("asking again after a run has given up", () => { await ctx.payload.jobs.run({ queue: "translations" }); expect( - second[0]?.job_id, + second[0]?.handle, "the first run is spent — Payload will never pick it up again, so naming it promises nothing" - ).not.toBe(first[0]?.job_id); + ).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/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 index b250241bb..eaa27701d 100644 --- 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 @@ -41,7 +41,7 @@ locale. `JobIdSchema` already demands only "non-empty string or finite number", 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, job_id }[]`. Rejected: a nested map mirroring the request — reads well +`{ 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. 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 3ae20b92d..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, toQueuedJobs } from "./model.js"; +import { EnqueueInputSchema, toAssignmentOutputs } from "./model.js"; import type { EnqueueConfig } from "./model.js"; export class EnqueueTranslationHandler { @@ -98,7 +98,7 @@ export class EnqueueTranslationHandler { return ServerResponse.success({ success: true, queued: tasks.length, - ...(assigned ? { jobs: toQueuedJobs(assigned) } : {}), + ...(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 c359275aa..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 @@ -25,18 +25,18 @@ export type EnqueueConfig = { availableCollections: Set; }; -/** One queued locale; `job_id` is what `/translate/cancel` takes back — opaque, never parsed. */ -export type QueuedJob = { +/** One enqueued locale, in the snake_case this surface answers in. */ +export type EnqueueAssignmentOutput = { collection_slug: string; collection_id: string; target_lng: string; - job_id: string; + handle: string; }; -export const toQueuedJobs = (assigned: EnqueueAssignment[]): QueuedJob[] => +export const toAssignmentOutputs = (assigned: EnqueueAssignment[]): EnqueueAssignmentOutput[] => assigned.map((entry) => ({ collection_slug: entry.collectionSlug, collection_id: entry.collectionId, target_lng: entry.targetLng, - job_id: entry.handle, + handle: entry.handle, })); 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 1f4247106..8c2cd6b5c 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 @@ -17,6 +17,11 @@ import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; * 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. + * + * And one limit: the obligations reach handles **this runner issued**. 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 { /** diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts index 049253373..e42f6b606 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts @@ -4,7 +4,6 @@ import type { TaskRunner } from "../TaskRunner.interface.js"; import type { EnqueueAssignment, TaskInput } from "../types.js"; const SLUG = "posts"; -const UNKNOWN_HANDLE = "no-runner-ever-issued-this-handle"; const input = (collectionId: string, targetLng: string): TaskInput => ({ collectionSlug: SLUG, @@ -195,31 +194,56 @@ export function assertTaskRunnerContract( }); }); - describe("an unknown handle is an answer, not a failure", () => { - it("answers run on an unknown handle with not_found", async () => { - expect(await runner.run(UNKNOWN_HANDLE)).toEqual({ success: false, error: "not_found" }); + 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 an unknown handle", async () => { + it("resolves cancel on a spent handle", async (ctx) => { + const handle = await spent(); + if (!handle) { + ctx.skip(); + return; + } + expect( - await settle(runner.cancel([UNKNOWN_HANDLE])), - "cancel on an unknown handle resolves rather than rejecting" + await settle(runner.cancel([handle])), + "cancel on a spent handle resolves rather than rejecting" ).toBe("resolved"); }); - it("cancels nothing when the handle is unknown", async (ctx) => { + it("cancels nothing when the handle is spent", async (ctx) => { const findByIds = runner.findByIds?.bind(runner); + const stale = await spent(); const answer = await enqueued([input(documentForThisCheck(1), "de")]); - if (!findByIds || !answer) { + if (!findByIds || !answer || !stale) { ctx.skip(); return; } const handles = [...new Set(answer.map((assignment) => assignment.handle))]; - await runner.cancel([UNKNOWN_HANDLE]); + await runner.cancel([stale]); const cancelled = (await findByIds(handles)).filter((task) => task.cancelled); - expect(cancelled, "cancel on an unknown handle changes nothing").toEqual([]); + expect(cancelled, "cancel on a spent handle changes nothing").toEqual([]); }); }); From 9465d2ddec97c8fb428c8c76cae905d92bca559d Mon Sep 17 00:00:00 2001 From: Siarhei Date: Thu, 8 Oct 2026 15:04:42 +0200 Subject: [PATCH 06/10] refactor(translator): wrap the runner for every host, not only some MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin decided whether to wrap a runner in `withQueuedNotification` by asking which callbacks the host had registered: wrap for `onQueued` or `onCancelled`, skip otherwise. The gate bought one avoided read — `cancel` resolves handles back to tasks only so it can name what it is stopping, and a host with no `onCancelled` has no use for that read. It cost more than it bought. Two hosts got structurally different runners depending on a setting unrelated to the difference: a wrapped runner normalizes `findByCollection`'s deprecated array argument, an unwrapped one does not. And the list of callbacks the wrapper raises was written twice — once in the wrapper, once in the gate — with nothing tying them together. That already produced a bug this branch had to fix: the gate asked only about `onQueued`, so a host registering only `onCancelled` was handed an undecorated runner and heard nothing. The runner is wrapped unconditionally now. Every callback the notifier fires is already a no-op when the host registered nothing, so wrapping costs a host that wants no reporting one resolved promise per enqueued task, and on cancel one read whose result is discarded. The check that pinned the old rule pinned the behaviour being removed, so it was rewritten to pin the new one instead, over two cases: a host registering only callbacks the task handler fires, and a host registering nothing at all. --- .../src/__tests__/plugin.test.ts | 12 ++++++-- .../translate-document/wireTranslateRunner.ts | 2 -- .../src/server/modules/lifecycle/index.ts | 1 - .../modules/lifecycle/needsDecoration.test.ts | 29 ------------------- .../modules/lifecycle/needsDecoration.ts | 7 ----- 5 files changed, 9 insertions(+), 42 deletions(-) delete mode 100644 packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.test.ts delete mode 100644 packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.ts diff --git a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts index 008c6d546..c7cfd77df 100644 --- a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts +++ b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts @@ -453,7 +453,10 @@ describe("translatorPlugin — lifecycle callbacks", () => { expect(taskRunner.enqueue).toHaveBeenCalled(); }); - it("does not wrap the runner in withQueuedNotification when lifecycle.onQueued is absent", async () => { + it.each([ + ["only the callbacks the task handler fires", { onCompleted: vi.fn() }], + ["nothing at all", {}], + ])("wraps the runner when the host registered %s", async (_label, lifecycle) => { vi.mocked(withQueuedNotification).mockClear(); const taskRunner = { enqueue: vi.fn().mockResolvedValue(undefined), @@ -467,7 +470,7 @@ describe("translatorPlugin — lifecycle callbacks", () => { }; const { result } = await build({ runner, - lifecycle: { onCompleted: vi.fn() }, + lifecycle, } as unknown as Partial); const enqueue = result.endpoints?.find((e) => e.path === "/translate/enqueue"); @@ -485,7 +488,10 @@ describe("translatorPlugin — lifecycle callbacks", () => { }; await enqueue?.handler?.(req as never); - expect(withQueuedNotification).not.toHaveBeenCalled(); + expect( + withQueuedNotification, + "every host gets the same runner — what it reports is the notifier's business, not the runner's" + ).toHaveBeenCalled(); expect(taskRunner.enqueue).toHaveBeenCalled(); }); 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 29ed9f82e..b32237662 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 @@ -9,7 +9,6 @@ import { taskFromHandlerInput, taskFromStored, withQueuedNotification, - needsDecoration, } from "../../modules/lifecycle/index.js"; import type { TranslationLifecycleCallbacks } from "../../modules/lifecycle/index.js"; import type { @@ -90,7 +89,6 @@ export function wireTranslateRunner({ const taskRunnerFactory: TaskRunnerFactory = { create: (payload) => { const taskRunner = runner.create(payload, runnerContext.handler); - if (!needsDecoration(lifecycle)) return taskRunner; return withQueuedNotification(taskRunner, new LifecycleNotifier(lifecycle, payload.logger)); }, }; 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 9485ed5d6..060f3d8da 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/index.ts @@ -2,7 +2,6 @@ // provenance opt-in, no schema/migration). export { LifecycleNotifier } from "./LifecycleNotifier.js"; export { withQueuedNotification } from "./withQueuedNotification.js"; -export { needsDecoration } from "./needsDecoration.js"; export { stillOwed } from "./stillOwed.js"; export { taskFromHandlerInput, taskFromInput, taskFromStored } from "./taskMapping.js"; export type { TranslationLifecycleCallbacks, TranslationTask } from "./types.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.test.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.test.ts deleted file mode 100644 index 071f615e2..000000000 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { needsDecoration } from "./needsDecoration.js"; -import type { TranslationLifecycleCallbacks } from "./types.js"; - -const noop = (): void => undefined; - -describe("needsDecoration", () => { - it.each<[string, TranslationLifecycleCallbacks]>([ - ["onQueued", { onQueued: noop }], - ["onCancelled", { onCancelled: noop }], - ["both of them", { onQueued: noop, onCancelled: noop }], - ["one of them beside a handler-raised one", { onCancelled: noop, onCompleted: noop }], - ])("wraps for a host registering %s", (_label, callbacks) => { - expect(needsDecoration(callbacks)).toBe(true); - }); - - it.each<[string, TranslationLifecycleCallbacks]>([ - ["nothing at all", {}], - ["only onCompleted", { onCompleted: noop }], - ["only onFailed", { onFailed: noop }], - ["both handler-raised ones", { onCompleted: noop, onFailed: noop }], - ])("does not wrap for a host registering %s", (_label, callbacks) => { - expect( - needsDecoration(callbacks), - "the task handler reports these whether or not the runner is wrapped" - ).toBe(false); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.ts deleted file mode 100644 index 32feb56c7..000000000 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/needsDecoration.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { TranslationLifecycleCallbacks } from "./types.js"; - -/** `onCompleted` and `onFailed` come from the task handler, wrapped or not. */ -export type NeedsDecoration = (callbacks: TranslationLifecycleCallbacks) => boolean; - -export const needsDecoration: NeedsDecoration = (callbacks) => - Boolean(callbacks.onQueued ?? callbacks.onCancelled); From 2e52966d76138534979ebc489ab472a3914530a8 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Thu, 8 Oct 2026 15:40:09 +0200 Subject: [PATCH 07/10] refactor(translator): drop three pieces of scaffolding the code did not need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three came out of review, none changes behaviour. **`LifecycleNotifier.cancelled` folded into `cancelling`.** It was a private two-line helper with exactly one call site, inside the loop of the method it sat next to. It was kept separate for symmetry with `queued`, `completed` and `failed` — but the symmetry does not hold: those three are public and take a task from the caller, while this one was private and took a task the class had just read itself. **The non-null assertion on `findByIds` is gone.** The wrapper re-exposed the optional method as `runner.findByIds!(ids)`, asserting something the compiler could not check: the guard sat in a ternary and the call inside an arrow, so the narrowing never reached it. The method is now bound into a local once, which both narrows honestly and keeps the receiver a class implementation needs. **The run-to-loop error channel no longer lives at module scope.** Payload replaces a task's error with a message-only copy, so the plugin keeps its own error in a `WeakMap` until the loop that reports it can read it back. That map was a module-level `const`, shared by every plugin instance in the process, although both handlers that use it are created in the same `configure()` call. It is a local there now, and `thrownByTheHandler.ts` — the module and its three exported functions — is deleted. The generated per-file suite loses one check with that file, which is the check that was scanning it. --- .../modules/lifecycle/LifecycleNotifier.ts | 10 ++++------ .../lifecycle/withQueuedNotification.ts | 6 +++++- .../PayloadJobsRunnerProvider.ts | 16 ++++++++++++---- .../payload-jobs-runner/thrownByTheHandler.ts | 19 ------------------- 4 files changed, 21 insertions(+), 30 deletions(-) delete mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/thrownByTheHandler.ts 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 e899e9b10..2e4cc41ad 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/LifecycleNotifier.ts @@ -44,8 +44,11 @@ export class LifecycleNotifier { } async cancelling(read: () => Promise): Promise { + const callback = this.callbacks.onCancelled; try { - for (const task of await read()) await this.cancelled(task); + for (const task of await read()) { + await this.safe("lifecycle.onCancelled", callback && (() => callback(task))); + } } catch (error) { this.logger.error({ err: error, @@ -54,11 +57,6 @@ export class LifecycleNotifier { } } - private 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/withQueuedNotification.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts index aa0379f92..f6734c26e 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts @@ -24,6 +24,10 @@ export function withQueuedNotification( runner: TaskRunner, notifier: LifecycleNotifier ): TaskRunner { + // Bound, not just read: a class implementation needs its own receiver, and binding is what lets + // the narrowing survive into the spread below. + const findByIds = runner.findByIds?.bind(runner); + return { async enqueue(tasks, scope) { await Promise.all(tasks.map((task) => notifier.queued(taskFromInput(task)))); @@ -38,6 +42,6 @@ export function withQueuedNotification( // pair, so it cannot pass the deprecated array form straight through. findByCollection: (collectionSlug, filter) => runner.findByCollection(collectionSlug, toTaskFilter(filter)), - ...(runner.findByIds ? { findByIds: (ids: string[]) => runner.findByIds!(ids) } : {}), + ...(findByIds ? { findByIds } : {}), }; } 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 314798a85..95d2542c0 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 @@ -13,7 +13,6 @@ import { handleOf } from "./handleOf.js"; import { readCollectionRef } from "./readCollectionRef.js"; import { owedIfGaveUp } from "./owedIfGaveUp.js"; import { retryLimitOf } from "./retryLimitOf.js"; -import { recallThrown, rememberThrown, theHandlerThrew } from "./thrownByTheHandler.js"; import type { TaskRunnerContext, TaskRunnerProvider } from "../TaskRunnerProvider.interface.js"; import type { Requester } from "../../../shared/payload/RequestScope.shapes.js"; import { asRequester } from "../../../shared/payload/RequestScope.shapes.js"; @@ -94,6 +93,15 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { const { taskName, workflowName, queueName, retries, autoRun } = this.config; const retryLimit = retryLimitOf(retries); const { handler, collections, reportFinalFailure } = 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[] = [ @@ -185,7 +193,7 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { { requester: requesterOf(args.input) } ); } catch (error) { - if (args.job) rememberThrown(args.job, error); + if (args.job) thrown.set(args.job, error); throw error; } return { output: {} }; @@ -216,11 +224,11 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { // A cancellation deletes the run's row under it, so Payload's own machinery throws — // that is not a locale giving up. const owed = - reportFinalFailure && theHandlerThrew(job) + reportFinalFailure && thrown.has(job) ? owedIfGaveUp(job, taskName, target, retryLimit) : []; if (owed.length > 0) { - await reportFinalFailure?.(req.payload, owed, recallThrown(job, error)); + await reportFinalFailure?.(req.payload, owed, thrown.get(job)); } throw error; } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/thrownByTheHandler.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/thrownByTheHandler.ts deleted file mode 100644 index 72b46268a..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/thrownByTheHandler.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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 object — 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 the task handler and the loop. - */ -const byRun = new WeakMap(); - -export const rememberThrown = (run: object, error: unknown): void => { - byRun.set(run, error); -}; - -export const theHandlerThrew = (run: object): boolean => byRun.has(run); - -export const recallThrown = (run: object, fallback: unknown): unknown => - byRun.has(run) ? byRun.get(run) : fallback; From a895a2e12044ea081071c2bb8f5983c2e720a433 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 9 Oct 2026 10:17:52 +0200 Subject: [PATCH 08/10] docs(translator): publish only what a consumer must name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EnqueueAssignment` and `Task` were added to the barrel so a third-party runner could annotate what it returns. A compile-only probe then showed a complete outside runner compiles **without naming either**: the shapes are inferred from `TaskRunnerProvider`, the one name that genuinely crosses the boundary. The export enabled nothing, so it goes. It never made them private, and the ADR says so plainly: `TaskRunnerProvider` is exported, its `create` returns a `TaskRunner`, and that interface answers with both types. They are emitted into the published declarations and reachable through `ReturnType` either way. A barrel decides how conveniently a consumer can name a type, not whether the type is part of the contract — so keeping a loose type out of it buys tidiness, never freedom to change it. ADR-0002 records the rule and the three questions it turns on, and the package guide points at it next to the `@since` rule, which governs the same file. --- ...-publish-only-what-a-consumer-must-name.md | 72 +++++++++++++++++++ packages/payload-plugin-translator/CLAUDE.md | 11 +++ .../docs/DEPRECATIONS.md | 3 +- .../payload-plugin-translator/src/index.ts | 7 -- .../payload-jobs-runner}/stillOwed.ts | 0 5 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0002-publish-only-what-a-consumer-must-name.md rename packages/payload-plugin-translator/src/server/modules/{lifecycle => task-runner/payload-jobs-runner}/stillOwed.ts (100%) 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 de23edd99..6fe0465e9 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -256,7 +256,8 @@ the single source of truth — code annotations link here by anchor instead of d - **What:** `TaskRunner.enqueue` resolving with nothing. - **Status:** live - **Deprecated:** 2026-10-07 / PR pending (issues #107, #110) -- **Replacement:** resolve with one `EnqueueAssignment` per requested locale. +- **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 diff --git a/packages/payload-plugin-translator/src/index.ts b/packages/payload-plugin-translator/src/index.ts index 128cf9713..d44b9ad05 100644 --- a/packages/payload-plugin-translator/src/index.ts +++ b/packages/payload-plugin-translator/src/index.ts @@ -57,13 +57,6 @@ export type { PayloadJobsRunnerOptions, TaskFilter, } from "./server/modules/task-runner/index.js"; -/** - * The names a third-party runner answers with: `EnqueueAssignment` from `enqueue`, `Task` from - * `findByIds`. - * - * @since 0.16.0 - */ -export type { EnqueueAssignment, Task } from "./server/modules/task-runner/index.js"; /** * A third-party `TaskRunnerProvider` has always had to accept a `RequestScope` — it is the second * parameter of `TaskRunner.enqueue` and of the `TaskHandler` it is handed — but could not name the diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/stillOwed.ts similarity index 100% rename from packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.ts rename to packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/stillOwed.ts From 3f0b005f04a40b95f39c100f634f30fdd17cb00b Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 9 Oct 2026 10:18:36 +0200 Subject: [PATCH 09/10] refactor(translator)!: one channel for everything the host is told MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places told the host what happened to a translation: a wrapper around the runner raised `queued` and `cancelled`, a catch inside the translation handler raised `completed` and `failed`, and a callback handed to the runner raised `failed` for locales a run gave up on. A boolean on the provider, `reportsFinalFailure`, picked between the last two — a switch that existed only because the knowledge of whether another attempt is coming lives in the runner while the decision to speak was taken in the handler. The runner now owns the whole story. It is handed `report` in its context and calls it for every assignment it accepted: `queued` when the work is taken, then at most one of `delivered`, `failed` or `cancelled`. The plugin turns those four into the host's callbacks and remains the only thing that knows a host exists. Gone with the switch: `withQueuedNotification` and its 24 checks, `TaskRunnerContext.reportFinalFailure`, `TaskRunner.findByIds` — cancellation reports itself now — `TaskHandlerInput.handle`, which nothing read once the handler stopped reporting, and two of the three task mappers. `EnqueueAssignment` gained `sourceLng` and `strategy` so one mapper can turn it into what the host sees; three existed only because the event arrived in three shapes. **Breaking for a third-party runner, and for nobody else.** `create` takes the context rather than the handler, `report` is required so that omitting it fails the build rather than leaving a host silent, and the removals above are compiler errors. A host that merely configures the plugin sees no change: the callbacks, their arguments, the runner factories and every HTTP response are as they were. All of it predates any release — `reportsFinalFailure` was added on this branch and never shipped. The invariants were written against the contract alone by an author that could not read the implementations, which did not exist yet. They ran red on a stub, 29 of 29, and the writing of them found five sentences the contract was missing: when a report becomes observable, 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 follows a `cancel`, and that a locale already reported `failed` is not owed. Two defects surfaced on a real database. Payload hands back a completed task's stored output instead of re-running it, so the loop reported `delivered` again on every later pass; it now reads the log before the call. And a handle is unique only while its row exists — SQLite reuses a deleted row's id, so a handle kept across a cancellation can later name another run. That one is a limit now written into the contract rather than a promise the code cannot keep. --- .../task-runner-contract.int.test.ts | 10 +- ...-10-08-one-channel-for-task-events.task.md | 157 ++++++ .../2026-10-08-task-events-red-run.txt | 465 ++++++++++++++++++ .../src/__tests__/plugin.test.ts | 230 +++------ .../translate-document/wireTranslateRunner.ts | 58 +-- .../modules/lifecycle/LifecycleNotifier.ts | 13 +- .../src/server/modules/lifecycle/index.ts | 4 +- .../modules/lifecycle/taskMapping.test.ts | 88 +--- .../server/modules/lifecycle/taskMapping.ts | 40 +- .../src/server/modules/lifecycle/types.ts | 2 - .../lifecycle/withQueuedNotification.test.ts | 411 ---------------- .../lifecycle/withQueuedNotification.ts | 47 -- .../task-runner/TaskRunner.interface.ts | 22 +- .../TaskRunnerProvider.interface.ts | 37 +- .../__tests__/TaskRunner.conformance.types.ts | 43 +- .../__tests__/TaskRunner.invariants.ts | 357 +++++++++----- .../PayloadJobsRunnerProvider.ts | 36 +- .../PayloadJobsTaskRunner.ts | 37 +- .../PayloadJobsRunnerProvider.test.ts | 1 + .../PayloadJobsTaskRunner.cancel.test.ts | 8 +- .../__tests__/PayloadJobsTaskRunner.test.ts | 8 +- .../__tests__}/stillOwed.test.ts | 4 +- .../payload-jobs-runner/assignmentOf.ts | 31 ++ .../payload-jobs-runner/owedIfGaveUp.ts | 2 +- .../payload-jobs-runner/stillOwed.ts | 2 +- .../sync-runner/SyncRunnerProvider.ts | 6 +- .../task-runner/sync-runner/SyncTaskRunner.ts | 28 +- .../__tests__/SyncRunnerProvider.test.ts | 5 +- .../__tests__/SyncTaskRunner.contract.test.ts | 4 +- .../__tests__/SyncTaskRunner.test.ts | 21 +- .../SyncTaskRunner.transaction.test.ts | 9 +- .../src/server/modules/task-runner/types.ts | 18 + 32 files changed, 1201 insertions(+), 1003 deletions(-) create mode 100644 packages/payload-plugin-translator/docs/plans/2026-10-08-one-channel-for-task-events.task.md create mode 100644 packages/payload-plugin-translator/docs/plans/artifacts/2026-10-08-task-events-red-run.txt delete mode 100644 packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.test.ts delete mode 100644 packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts rename packages/payload-plugin-translator/src/server/modules/{lifecycle => task-runner/payload-jobs-runner/__tests__}/stillOwed.test.ts (96%) create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/assignmentOf.ts 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 index 14a63cc44..296d17a0f 100644 --- a/apps/dev/src/integration/translator/task-runner-contract.int.test.ts +++ b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts @@ -1,5 +1,5 @@ import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; -import { afterAll, beforeAll } from "vitest"; +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"; @@ -14,6 +14,10 @@ afterAll(async () => { await ctx?.cleanup(); }); -assertTaskRunnerContract("PayloadJobsTaskRunner", () => - createPayloadJobsRunner({ autoRun: false }).create(ctx.payload, async () => undefined) +assertTaskRunnerContract("PayloadJobsTaskRunner", (report) => + createPayloadJobsRunner({ autoRun: false }).create(ctx.payload, { + handler: vi.fn().mockResolvedValue(undefined), + collections: [], + report, + }) ); 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..48ae8f460 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-08-one-channel-for-task-events.task.md @@ -0,0 +1,157 @@ +# 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, not optional.** A runner that silently fails to implement it would +leave its host hearing nothing, and no compiler would say so. Required means a third-party runner +fails to build until it reports, which is the loudest available signal. + +**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 | A runner that does not implement `report` fails to build | compile-only fixture under `check-types` | removing `report` breaks the build | +| 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)_ 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 c7cfd77df..3253b695c 100644 --- a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts +++ b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts @@ -1,5 +1,6 @@ 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 { translatorPlugin } from "../plugin.js"; import { AnyAccessGuard } from "../server/shared/access/AnyAccessGuard.js"; @@ -8,7 +9,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 +18,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 +345,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,110 +356,90 @@ 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.each([ - ["only the callbacks the task handler fires", { onCompleted: vi.fn() }], - ["nothing at all", {}], - ])("wraps the runner when the host registered %s", async (_label, lifecycle) => { - 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, - } 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, - "every host gets the same runner — what it reports is the notifier's business, not the runner's" - ).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); + it.each([ + ["queued", "onQueued", { state: "queued" }], + ["delivered", "onCompleted", { state: "delivered" }], + ["failed", "onFailed", { state: "failed", error: new Error("provider down") }], + ["cancelled", "onCancelled", { state: "cancelled" }], + ])("turns %s into %s", async (_label, callbackName, event) => { + const callback = vi.fn(); + const context = await contextHandedToTheRunner({ [callbackName]: callback }); - await capturedHandler(runner)(mockPayload(), handlerInput()); + await context.report(payload, assignment, event as never); - expect(onCompleted).toHaveBeenCalledTimes(1); - expect(onFailed).not.toHaveBeenCalled(); - }); + expect(callback, `${String(event)} must reach ${callbackName}`).toHaveBeenCalledWith( + expect.objectContaining({ collection: "posts", id: "doc-1", targetLng: "de" }), + ...(callbackName === "onFailed" ? [expect.any(Error)] : []) + ); + }); - 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); + it("tells a host nothing it did not register for", async () => { + const onCompleted = vi.fn(); + const context = await contextHandedToTheRunner({ onCompleted }); - await expect( - capturedHandler(runner)(mockPayload(), handlerInput({ collection: "unknown" })) - ).rejects.toThrow(); + await context.report(payload, assignment, { state: "cancelled" }); - expect(onFailed).toHaveBeenCalledTimes(1); - expect(onCompleted).not.toHaveBeenCalled(); + expect(onCompleted, "a cancellation is not a completion").not.toHaveBeenCalled(); + }); }); }); 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 b32237662..da4ea986c 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,12 +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, - taskFromStored, - 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, @@ -58,39 +53,38 @@ export function wireTranslateRunner({ const runnerContext: TaskRunnerContext = { handler: async (payload, input, scope) => { - 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) { - if (!runner.reportsFinalFailure) await notifier.failed(task, error); - throw error; // rethrow so the runner marks the job failed - } - await notifier.completed(task); + await translateHandler.handle( + payload, + { + collection: input.collection, + collectionId: input.collectionId, + sourceLng: input.sourceLng, + targetLng: input.targetLng, + strategy: input.strategy, + publishOnTranslation: input.publishOnTranslation, + }, + scope + ); }, collections, - reportFinalFailure: async (payload, owed, error) => { + report: (payload, assignment, event) => { const notifier = new LifecycleNotifier(lifecycle, payload.logger); - for (const stopped of owed) await notifier.failed(taskFromStored(stopped), error); + 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); + } }, }; const taskRunnerFactory: TaskRunnerFactory = { - create: (payload) => { - const taskRunner = runner.create(payload, runnerContext.handler); - 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 2e4cc41ad..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,18 +43,9 @@ export class LifecycleNotifier { await this.safe("lifecycle.onFailed", () => callback(task, error)); } - async cancelling(read: () => Promise): Promise { + cancelled(task: TranslationTask): Promise { const callback = this.callbacks.onCancelled; - try { - for (const task of await read()) { - await this.safe("lifecycle.onCancelled", callback && (() => callback(task))); - } - } catch (error) { - this.logger.error({ - err: error, - msg: "translator: could not read what a cancellation is stopping", - }); - } + return this.safe("lifecycle.onCancelled", callback && (() => callback(task))); } private async safe(name: string, thunk?: () => void | Promise): Promise { 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 060f3d8da..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,7 +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 { stillOwed } from "./stillOwed.js"; -export { taskFromHandlerInput, taskFromInput, taskFromStored } 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 c5fde1251..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,96 +1,24 @@ import { describe, it, expect } from "vitest"; -import type { TaskInput } from "../task-runner/types.js"; -import type { TaskHandlerInput } from "../task-runner/TaskRunnerProvider.interface.js"; -import type { Task } from "../task-runner/types.js"; -import { taskFromInput, taskFromHandlerInput, taskFromStored } 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, - }; - expect(taskFromInput(input)).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", - }); - }); - - it("carries the handle when the execution knows one", () => { - const input: TaskHandlerInput = { - collection: "pages", - collectionId: "doc-2", - sourceLng: "en", - targetLng: "fr", - strategy: "overwrite", - publishOnTranslation: false, handle: "run-9", }; - expect(taskFromHandlerInput(input)).toEqual({ - collection: "pages", - id: "doc-2", - sourceLng: "en", - targetLng: "fr", - strategy: "overwrite", - handle: "run-9", - }); - }); -}); -describe("taskFromStored", () => { - it("maps every public field, names the run as the handle, and omits publishOnTranslation", () => { - const stored: Task = { - id: "run-9", - status: "pending", - input: { - collectionSlug: "posts", - collectionId: "doc-3", - sourceLng: "en", - targetLng: "es", - strategy: "skip_existing", - publishOnTranslation: true, - }, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - cancelled: false, - }; - expect(taskFromStored(stored)).toEqual({ + expect(taskFromAssignment(assignment)).toEqual({ collection: "posts", - id: "doc-3", + id: "doc-1", sourceLng: "en", - targetLng: "es", + targetLng: "de", strategy: "skip_existing", handle: "run-9", }); diff --git a/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts b/packages/payload-plugin-translator/src/server/modules/lifecycle/taskMapping.ts index 33c194abd..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,31 +1,15 @@ -import type { Task, 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, - ...(input.handle === undefined ? {} : { handle: input.handle }), -}); - -export const taskFromStored = (task: Task): TranslationTask => ({ - collection: task.input.collectionSlug, - id: task.input.collectionId, - sourceLng: task.input.sourceLng, - targetLng: task.input.targetLng, - strategy: task.input.strategy, - handle: task.id, +/** + * `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 8060a1115..60c6a135d 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts @@ -60,8 +60,6 @@ export type TranslationLifecycleCallbacks = { * 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. * - * Never fires for a runner without `findByIds`. - * * @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 4c669eb32..000000000 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -import type { CollectionSlug } from "payload"; -import { describe, expect, it, vi } from "vitest"; -import type { TaskFilter, TaskRunner } from "../task-runner/TaskRunner.interface.js"; -import type { - EnqueueAssignment, - RunResult, - Task, - TaskInput, - TaskStatus, -} from "../task-runner/types.js"; -import { LifecycleNotifier } from "./LifecycleNotifier.js"; -import type { TranslationLifecycleCallbacks, TranslationTask } from "./types.js"; -import { withQueuedNotification } from "./withQueuedNotification.js"; - -const makeLogger = () => ({ error: vi.fn() }); - -/** A notifier whose logger the test can read: a swallowed failure surfaces nowhere else. */ -const notifierFor = ( - callbacks: TranslationLifecycleCallbacks, - logger: { error: (obj: unknown) => void } = makeLogger() -) => new LifecycleNotifier(callbacks, logger); - -const taskInput = (overrides: Partial = {}): TaskInput => ({ - collectionSlug: "posts", - collectionId: "doc-1", - sourceLng: "en", - targetLng: "de", - strategy: "overwrite", - publishOnTranslation: false, - ...overrides, -}); - -const storedTask = (id: string, input: TaskInput, status: TaskStatus = "pending"): Task => ({ - id, - status, - input, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - cancelled: false, -}); - -/** The runner the wrapper goes around. `findByIds` is absent on purpose — it is the optional part - * of the contract. */ -const makeRunner = (parts: Partial = {}): TaskRunner => ({ - enqueue: vi.fn(async () => undefined), - cancel: vi.fn(async () => undefined), - run: vi.fn(async () => ({ success: true as const })), - findByCollection: vi.fn(async () => []), - ...parts, -}); - -describe("withQueuedNotification — enqueue", () => { - it("raises queued for each task it was given", async () => { - const onQueued = vi.fn(); - const tasks = [taskInput(), taskInput({ collectionId: "doc-2", targetLng: "fr" })]; - - await withQueuedNotification(makeRunner(), notifierFor({ onQueued })).enqueue(tasks); - - expect(onQueued).toHaveBeenCalledTimes(2); - expect(onQueued).toHaveBeenCalledWith( - expect.objectContaining({ collection: "posts", id: "doc-1", targetLng: "de" }) - ); - expect(onQueued).toHaveBeenCalledWith( - expect.objectContaining({ collection: "posts", id: "doc-2", targetLng: "fr" }) - ); - }); - - it("answers with the handles the wrapped runner answered", async () => { - const assignments: EnqueueAssignment[] = [ - { collectionSlug: "posts", collectionId: "doc-1", targetLng: "de", handle: "run-1" }, - ]; - const wrapped = makeRunner({ enqueue: vi.fn(async () => assignments) }); - - const answer = await withQueuedNotification(wrapped, notifierFor({})).enqueue([taskInput()]); - - expect(answer).toEqual(assignments); - }); - - it("answers with nothing when the wrapped runner answered nothing", async () => { - const enqueue = vi.fn(async () => undefined); - - const answer = await withQueuedNotification(makeRunner({ enqueue }), notifierFor({})).enqueue([ - taskInput(), - ]); - - expect(enqueue).toHaveBeenCalledTimes(1); - expect(answer).toBeUndefined(); - }); - - it("asks the wrapped runner with the tasks and the scope it was given", async () => { - const enqueue = vi.fn(async () => undefined); - const tasks = [taskInput()]; - const scope = { transactionID: "tx-1" }; - - await withQueuedNotification(makeRunner({ enqueue }), notifierFor({})).enqueue(tasks, scope); - - expect(enqueue).toHaveBeenCalledWith(tasks, scope); - }); - - it("raises queued for every task before the wrapped runner is asked", async () => { - const order: string[] = []; - const onQueued = vi.fn(() => { - order.push("queued"); - }); - const enqueue = vi.fn(async () => { - order.push("enqueue"); - }); - - await withQueuedNotification(makeRunner({ enqueue }), notifierFor({ onQueued })).enqueue([ - taskInput(), - taskInput({ targetLng: "fr" }), - ]); - - expect(order).toEqual(["queued", "queued", "enqueue"]); - }); - - it("keeps queued ahead of a completed the runner reports from inside the enqueue call", async () => { - const order: string[] = []; - const notifier = notifierFor({ - onQueued: async () => { - await Promise.resolve(); - order.push("queued"); - }, - onCompleted: () => { - order.push("completed"); - }, - }); - const translated: TranslationTask = { - collection: "posts", - id: "doc-1", - sourceLng: "en", - targetLng: "de", - strategy: "overwrite", - handle: "run-1", - }; - const enqueue = vi.fn(async () => { - await notifier.completed(translated); - }); - - await withQueuedNotification(makeRunner({ enqueue }), notifier).enqueue([taskInput()]); - - expect(order).toEqual(["queued", "completed"]); - }); - - it("reports a queued task with no handle", async () => { - const onQueued = vi.fn(); - const assignments: EnqueueAssignment[] = [ - { collectionSlug: "posts", collectionId: "doc-1", targetLng: "de", handle: "run-1" }, - ]; - const wrapped = makeRunner({ enqueue: vi.fn(async () => assignments) }); - - await withQueuedNotification(wrapped, notifierFor({ onQueued })).enqueue([taskInput()]); - - expect(onQueued).toHaveBeenCalledTimes(1); - const queued = onQueued.mock.calls[0]?.[0] as TranslationTask; - expect(queued.handle).toBeUndefined(); - }); -}); - -describe("withQueuedNotification — cancel", () => { - it("announces, once per locale, what the cancelled runs still owed", async () => { - const onCancelled = vi.fn(); - const owed = [storedTask("t1", taskInput()), storedTask("t2", taskInput({ targetLng: "fr" }))]; - const wrapped = makeRunner({ findByIds: vi.fn(async () => owed) }); - - await withQueuedNotification(wrapped, notifierFor({ onCancelled })).cancel(["t1", "t2"]); - - expect(onCancelled).toHaveBeenCalledTimes(2); - expect(onCancelled).toHaveBeenCalledWith( - expect.objectContaining({ - collection: "posts", - id: "doc-1", - sourceLng: "en", - targetLng: "de", - strategy: "overwrite", - }) - ); - expect(onCancelled).toHaveBeenCalledWith(expect.objectContaining({ targetLng: "fr" })); - }); - - it("reads the records of the handles it was given", async () => { - const findByIds = vi.fn(async () => [storedTask("t1", taskInput())]); - - await withQueuedNotification( - makeRunner({ findByIds }), - notifierFor({ onCancelled: vi.fn() }) - ).cancel(["t1", "t2"]); - - expect(findByIds).toHaveBeenCalledWith(["t1", "t2"]); - }); - - it("announces before delegating the cancellation", async () => { - const order: string[] = []; - const cancel = vi.fn(async () => { - order.push("cancel"); - }); - const findByIds = vi.fn(async () => [storedTask("t1", taskInput())]); - const notifier = notifierFor({ - onCancelled: () => { - order.push("cancelled"); - }, - }); - - await withQueuedNotification(makeRunner({ cancel, findByIds }), notifier).cancel(["t1"]); - - expect(order).toEqual(["cancelled", "cancel"]); - }); - - it("lets an announcement finish before delegating the cancellation", async () => { - const order: string[] = []; - const cancel = vi.fn(async () => { - order.push("cancel"); - }); - const findByIds = vi.fn(async () => [storedTask("t1", taskInput())]); - // Settles on a timer, not a microtask: an announcement that is started but not awaited loses - // this race. - const notifier = notifierFor({ - onCancelled: () => - new Promise((resolve) => { - setTimeout(() => { - order.push("cancelled"); - resolve(); - }, 10); - }), - }); - - await withQueuedNotification(makeRunner({ cancel, findByIds }), notifier).cancel(["t1"]); - - expect(order).toEqual(["cancelled", "cancel"]); - }); - - it("announces the handle of the run being cancelled", async () => { - const onCancelled = vi.fn(); - const records = [ - storedTask("run-1", taskInput()), - storedTask("run-2", taskInput({ targetLng: "fr" })), - ]; - const wrapped = makeRunner({ findByIds: vi.fn(async () => records) }); - - await withQueuedNotification(wrapped, notifierFor({ onCancelled })).cancel(["run-1", "run-2"]); - - expect(onCancelled).toHaveBeenCalledWith( - expect.objectContaining({ targetLng: "de", handle: "run-1" }) - ); - expect(onCancelled).toHaveBeenCalledWith( - expect.objectContaining({ targetLng: "fr", handle: "run-2" }) - ); - }); - - it("does not announce a locale the run had already translated", async () => { - const onCancelled = vi.fn(); - const records = [ - storedTask("t1", taskInput(), "completed"), - storedTask("t2", taskInput({ targetLng: "fr" })), - ]; - const wrapped = makeRunner({ findByIds: vi.fn(async () => records) }); - - await withQueuedNotification(wrapped, notifierFor({ onCancelled })).cancel(["t1", "t2"]); - - expect(onCancelled).toHaveBeenCalledTimes(1); - expect(onCancelled).toHaveBeenCalledWith(expect.objectContaining({ targetLng: "fr" })); - }); - - it("cancels as before when the runner cannot resolve handles", async () => { - const cancel = vi.fn(async () => undefined); - - const wrapper = withQueuedNotification( - makeRunner({ cancel }), - notifierFor({ onCancelled: vi.fn() }) - ); - - await expect(wrapper.cancel(["t1", "t2"])).resolves.toBeUndefined(); - expect(cancel).toHaveBeenCalledWith(["t1", "t2"]); - }); - - it("announces nothing when the runner cannot resolve handles", async () => { - const cancel = vi.fn(async () => undefined); - const onCancelled = vi.fn(); - - await withQueuedNotification(makeRunner({ cancel }), notifierFor({ onCancelled })).cancel([ - "t1", - ]); - - expect(cancel).toHaveBeenCalledTimes(1); - expect(onCancelled).not.toHaveBeenCalled(); - }); - - it("does not ask a runner that cannot resolve handles to resolve them", async () => { - const cancel = vi.fn(async () => undefined); - const logger = makeLogger(); - - await withQueuedNotification( - makeRunner({ cancel }), - notifierFor({ onCancelled: vi.fn() }, logger) - ).cancel(["t1"]); - - expect(cancel).toHaveBeenCalledTimes(1); - expect(logger.error).not.toHaveBeenCalled(); - }); - - it("swallows a read that fails, and still cancels", async () => { - const cancel = vi.fn(async () => undefined); - const findByIds = vi.fn(() => Promise.reject(new Error("database unreachable"))); - const wrapper = withQueuedNotification( - makeRunner({ cancel, findByIds }), - notifierFor({ onCancelled: vi.fn() }) - ); - - await expect(wrapper.cancel(["t1"])).resolves.toBeUndefined(); - expect(findByIds).toHaveBeenCalledTimes(1); - expect(cancel).toHaveBeenCalledWith(["t1"]); - }); - - it("records a read that fails rather than passing it off as nothing to announce", async () => { - const failure = new Error("database unreachable"); - const findByIds = vi.fn(() => Promise.reject(failure)); - const logger = makeLogger(); - - await withQueuedNotification( - makeRunner({ findByIds }), - notifierFor({ onCancelled: vi.fn() }, logger) - ).cancel(["t1"]); - - expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ err: failure })); - }); - - it("still cancels when the host's onCancelled throws", async () => { - const cancel = vi.fn(async () => undefined); - const findByIds = vi.fn(async () => [storedTask("t1", taskInput())]); - const notifier = notifierFor({ - onCancelled: () => { - throw new Error("host blew up"); - }, - }); - const wrapper = withQueuedNotification(makeRunner({ cancel, findByIds }), notifier); - - await expect(wrapper.cancel(["t1"])).resolves.toBeUndefined(); - expect(cancel).toHaveBeenCalledWith(["t1"]); - }); -}); - -describe("withQueuedNotification — the methods that only pass through", () => { - it("answers run exactly as the wrapped runner", async () => { - const notFound: RunResult = { success: false, error: "not_found" }; - const run = vi.fn(async (taskId: string) => - taskId === "t1" ? notFound : { success: true as const } - ); - - const answer = await withQueuedNotification(makeRunner({ run }), notifierFor({})).run("t1"); - - expect(answer).toEqual(notFound); - }); - - it("answers findByCollection exactly as the wrapped runner", async () => { - const pending = [storedTask("t1", taskInput())]; - const findByCollection = vi.fn( - async (_slug: CollectionSlug, filter?: TaskFilter | Array) => - !Array.isArray(filter) && filter?.excludeCompleted ? pending : [] - ); - - const answer = await withQueuedNotification( - makeRunner({ findByCollection }), - notifierFor({}) - ).findByCollection("posts", { excludeCompleted: true }); - - expect(answer).toEqual(pending); - }); - - it("keeps the optional findByIds available to anything downstream", async () => { - const owed = [storedTask("t1", taskInput())]; - const findByIds = vi.fn(async (taskIds: string[]) => (taskIds[0] === "t1" ? owed : [])); - const wrapper = withQueuedNotification(makeRunner({ findByIds }), notifierFor({})); - - expect(wrapper.findByIds).toBeDefined(); - await expect(wrapper.findByIds?.(["t1"])).resolves.toEqual(owed); - }); - - it("leaves findByIds absent when the wrapped runner cannot resolve handles", () => { - const wrapper = withQueuedNotification(makeRunner(), notifierFor({})); - - expect(wrapper.findByIds).toBeUndefined(); - }); - - it("tells the host nothing for the methods that only pass through", async () => { - const callbacks = { - onQueued: vi.fn(), - onCompleted: vi.fn(), - onFailed: vi.fn(), - onCancelled: vi.fn(), - }; - const run = vi.fn(async () => ({ success: true as const })); - const findByCollection = vi.fn(async () => []); - const findByIds = vi.fn(async () => []); - const wrapper = withQueuedNotification( - makeRunner({ run, findByCollection, findByIds }), - notifierFor(callbacks) - ); - - await wrapper.run("t1"); - await wrapper.findByCollection("posts"); - await wrapper.findByIds?.(["t1"]); - - expect(run).toHaveBeenCalledTimes(1); - expect(findByCollection).toHaveBeenCalledTimes(1); - expect(findByIds).toHaveBeenCalledTimes(1); - for (const callback of Object.values(callbacks)) { - expect(callback).not.toHaveBeenCalled(); - } - }); -}); 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 f6734c26e..000000000 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/withQueuedNotification.ts +++ /dev/null @@ -1,47 +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 type { TranslationTask } from "./types.js"; -import { stillOwed } from "./stillOwed.js"; -import { taskFromInput, taskFromStored } from "./taskMapping.js"; - -async function stopping(runner: TaskRunner, taskIds: string[]): Promise { - if (!runner.findByIds || taskIds.length === 0) return []; - return stillOwed(await runner.findByIds(taskIds)).map(taskFromStored); -} - -/** - * Wrap a {@link TaskRunner} so it reports to the host; every method otherwise delegates unchanged. - * - * `queued` is raised before the wrapped runner is asked: a runner may translate inline and report - * `completed` from inside that call. - * - * `cancel` awaits its announcements before delegating — the records are gone afterwards. - * - * @since 0.16.0 `cancel` announces; `enqueue` answers with the handles. - */ -export function withQueuedNotification( - runner: TaskRunner, - notifier: LifecycleNotifier -): TaskRunner { - // Bound, not just read: a class implementation needs its own receiver, and binding is what lets - // the narrowing survive into the spread below. - const findByIds = runner.findByIds?.bind(runner); - - return { - async enqueue(tasks, scope) { - await Promise.all(tasks.map((task) => notifier.queued(taskFromInput(task)))); - return runner.enqueue(tasks, scope); - }, - async cancel(taskIds) { - await notifier.cancelling(() => stopping(runner, taskIds)); - return 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)), - ...(findByIds ? { findByIds } : {}), - }; -} 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 8c2cd6b5c..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 @@ -18,10 +18,13 @@ import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; * - **A handle is never parsed.** Callers compare it, store it and hand it back. * - **Nothing promises an order.** Callers that need one sort. * - * And one limit: the obligations reach handles **this runner issued**. 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. + * 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 { /** @@ -41,19 +44,10 @@ export interface TaskRunner { * 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. - * "Cancelled" is not "did not happen" — only {@link TaskRunner.findByIds} says what was still owed. + * Report `cancelled` for each locale the run still owed, and for none it had delivered. */ cancel(taskIds: string[]): Promise; - /** - * One {@link Task} per handle **and target locale**, not one per handle. - * - * Optional: a runner that omits it works unchanged, and `onCancelled` never fires for it. - * - * @since 0.16.0 - */ - findByIds?(taskIds: string[]): Promise; - /** * Execute a task immediately. * Returns error status if task not found, already running, or completed. 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 3a0b190eb..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, Task } 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"; @@ -14,13 +14,6 @@ export type TaskHandlerInput = { targetLng: string; strategy: TranslationStrategyName; publishOnTranslation: boolean; - /** - * The run this execution belongs to, as `enqueue` answered. Absent when the runner does not name - * its work. - * - * @since 0.16.0 - */ - handle?: string; }; /** @@ -39,12 +32,20 @@ export type TaskRunnerContext = { handler: TaskHandler; collections: CollectionSlug[]; /** - * Call once when a run has stopped for good, with every locale it still owed. A runner that calls - * it declares {@link TaskRunnerProvider.reportsFinalFailure}. + * 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 */ - reportFinalFailure?: (payload: Payload, owed: Task[], error: unknown) => Promise; + report: (payload: Payload, assignment: EnqueueAssignment, event: TaskEvent) => Promise; }; /** @@ -78,19 +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; - - /** - * True when this runner tells the caller about a final failure itself, through - * {@link TaskRunnerContext.reportFinalFailure}. - * - * A runner that retries must set it: a throw out of the handler may be followed by another - * attempt, so reporting at that moment would call a transient failure final. A runner that does - * not retry leaves it unset, and a throw is reported where it happens. - * - * @since 0.16.0 - */ - reportsFinalFailure?: boolean; + 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 index 1e43b5330..a19b3a036 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts @@ -1,43 +1,44 @@ import type { Config } from "payload"; -import type { EnqueueAssignment, Task, TaskRunnerProvider } from "../../../../index.js"; +import type { TaskRunnerProvider } from "../../../../index.js"; /** * A compile-only fixture, enforced by `check-types` rather than by a running test. * - * A runner is implemented outside this package, so it is written against the published root barrel — - * which is what this file imports, rather than the internal paths no installer can reach. Widening - * `enqueue` must not break an implementation written before the widening: both generations below - * must keep satisfying the contract, and narrowing `enqueue` to require assignments makes this file - * fail to compile, which is the point of it existing. + * It imports one name from the published barrel, which is all a third-party runner can reach. + * Narrowing `enqueue` to require assignments must make this file fail to compile, and so must + * dropping `report` from a provider. */ -const unqueues = { - cancel: async (): Promise => undefined, - run: async () => ({ success: false, error: "not_found" }) as const, - findByCollection: async (): Promise => [], -}; - const configure = () => (config: Config) => config; -/** Pre-0.16.0: answers with nothing, and has no by-handle read. */ export const silent: TaskRunnerProvider = { - create: () => ({ ...unqueues, enqueue: async () => undefined }), + create: () => ({ + enqueue: async () => undefined, + cancel: async (): Promise => undefined, + run: async () => ({ success: false, error: "not_found" }) as const, + findByCollection: async () => [], + }), configure, }; -/** Post-0.16.0: one assignment per requested locale, and resolves handles. */ export const answering: TaskRunnerProvider = { - create: () => ({ - ...unqueues, - enqueue: async (tasks): Promise => - tasks.map((task) => ({ + 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", - })), - findByIds: async (): Promise => [], + })); + 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 index e42f6b606..bc639ef4f 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { TaskRunner } from "../TaskRunner.interface.js"; -import type { EnqueueAssignment, TaskInput } from "../types.js"; +import type { TaskRunnerContext } from "../TaskRunnerProvider.interface.js"; +import type { EnqueueAssignment, TaskEvent, TaskInput } from "../types.js"; const SLUG = "posts"; @@ -19,35 +20,59 @@ type Addressed = { collectionSlug: string; collectionId: string; targetLng: stri const addressOf = (target: Addressed): string => `${target.collectionSlug}/${target.collectionId}/${target.targetLng}`; -const localeOf = (handle: string, targetLng: string): string => `${handle}/${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` or `types.ts`; the nested - * `describe` names quote the obligation they belong to. Checks that need the optional - * `findByIds`, or a handle from a runner that answers `enqueue` with nothing, skip themselves - * instead of failing. + * 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 - builds a ready-to-use runner; called fresh for every check + * @param make - called fresh for every check */ -export function assertTaskRunnerContract( - name: string, - make: () => TaskRunner | Promise -): void { +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; - runner = await make(); + reported = []; + runner = await make(record); }); const enqueued = async (tasks: TaskInput[]): Promise => { @@ -55,6 +80,42 @@ export function assertTaskRunnerContract( 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([]); @@ -68,16 +129,16 @@ export function assertTaskRunnerContract( expect(answer ?? [], "cancel([]) answers [] or undefined").toEqual([]); }); - it("answers an empty findByIds", async (ctx) => { - const findByIds = runner.findByIds?.bind(runner); - if (!findByIds) { - ctx.skip(); - return; - } + it("reports nothing about an empty enqueue", async () => { + await runner.enqueue([]); - const answer: unknown = await findByIds([]); + expect(reported, "an empty request accepts no assignment to report").toEqual([]); + }); + + it("reports nothing about an empty cancel", async () => { + await runner.cancel([]); - expect(answer ?? [], "findByIds([]) answers [] or undefined").toEqual([]); + expect(reported, "no handle names work to stop, so nothing settled").toEqual([]); }); it("answers about no documents, not about all of them", async () => { @@ -87,10 +148,7 @@ export function assertTaskRunnerContract( describe("a handle is a non-empty string", () => { it("names each assignment with a non-empty handle", async (ctx) => { - const answer = await enqueued([ - input(documentForThisCheck(1), "de"), - input(documentForThisCheck(1), "fr"), - ]); + const answer = await enqueued(twoLocales()); if (!answer) { ctx.skip(); return; @@ -107,23 +165,17 @@ export function assertTaskRunnerContract( ctx.skip(); return; } - const handles = [...new Set(answer.map((assignment) => assignment.handle))]; expect( - await settle(runner.cancel(handles)), + 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", () => { - const request = [ - input(documentForThisCheck(1), "de"), - input(documentForThisCheck(1), "fr"), - input(documentForThisCheck(2), "de"), - ]; - it("answers only locales the request listed", async (ctx) => { + const request = threeLocales(); const answer = await enqueued(request); if (!answer) { ctx.skip(); @@ -137,7 +189,7 @@ export function assertTaskRunnerContract( }); it("answers a locale at most once per document", async (ctx) => { - const answer = await enqueued(request); + const answer = await enqueued(threeLocales()); if (!answer) { ctx.skip(); return; @@ -164,6 +216,7 @@ export function assertTaskRunnerContract( }); it("answers an assignment per requested locale", async (ctx) => { + const request = threeLocales(); const answer = await enqueued(request); if (!answer) { ctx.skip(); @@ -194,112 +247,192 @@ export function assertTaskRunnerContract( }); }); - 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; - }; + describe("what became of every accepted assignment reaches the plugin", () => { + it("reports queued for every requested locale", async () => { + const request = twoLocales(); - it("answers run on a spent handle with not_found", async (ctx) => { - const handle = await spent(); - if (!handle) { + 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) + ) + ); - expect(await runner.run(handle)).toEqual({ success: false, error: "not_found" }); + 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("resolves cancel on a spent handle", async (ctx) => { - const handle = await spent(); - if (!handle) { + 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")); - expect( - await settle(runner.cancel([handle])), - "cancel on a spent handle resolves rather than rejecting" - ).toBe("resolved"); + 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("cancels nothing when the handle is spent", async (ctx) => { - const findByIds = runner.findByIds?.bind(runner); - const stale = await spent(); - const answer = await enqueued([input(documentForThisCheck(1), "de")]); - if (!findByIds || !answer || !stale) { + it("reports at most one terminal event per cancelled assignment", async (ctx) => { + const answer = await enqueued(twoLocales()); + if (!answer) { ctx.skip(); return; } - const handles = [...new Set(answer.map((assignment) => assignment.handle))]; - await runner.cancel([stale]); - const cancelled = (await findByIds(handles)).filter((task) => task.cancelled); + await runner.cancel(handlesOf(answer)); - expect(cancelled, "cancel on a spent handle changes nothing").toEqual([]); + const several = terminalsPerAssignment().filter( + ([, states]) => states.length > 1 && !cancelledAndStillDelivered(states) + ); + expect( + several, + "one assignment settles once, bar a locale already executing when cancelled" + ).toEqual([]); }); - }); - describe("findByIds resolves handles to the tasks they stand for", () => { - it("answers only tasks for the handles asked about", async (ctx) => { - const findByIds = runner.findByIds?.bind(runner); - const answer = await enqueued([input(documentForThisCheck(1), "de")]); - if (!findByIds || !answer) { + 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 handles = new Set(answer.map((assignment) => assignment.handle)); + const given = new Set(handlesOf(cancelled)); + const untouched = new Set(spared.filter((a) => !given.has(a.handle)).map(assignmentKey)); - const foreign = (await findByIds([...handles])).filter((task) => !handles.has(task.id)); + await runner.cancel([...given]); - expect(foreign, "every task stands for one of the handles asked about").toEqual([]); + 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("announces each target locale of a handle separately", async (ctx) => { - const findByIds = runner.findByIds?.bind(runner); - const answer = await enqueued([ - input(documentForThisCheck(1), "de"), - input(documentForThisCheck(1), "fr"), - ]); - if (!findByIds || !answer) { + it("answers run on a spent handle with not_found", async (ctx) => { + const handle = await spent(); + if (!handle) { ctx.skip(); return; } - const handles = [...new Set(answer.map((assignment) => assignment.handle))]; - const tasks = await findByIds(handles); - const announced = new Set(tasks.map((task) => localeOf(task.id, task.input.targetLng))); - - const missing = answer - .map((assignment) => localeOf(assignment.handle, assignment.targetLng)) - .filter((locale) => !announced.has(locale)); - expect(missing, "one Task per handle and target locale").toEqual([]); + expect(await runner.run(handle)).toEqual({ success: false, error: "not_found" }); }); - it("announces a target locale once per handle", async (ctx) => { - const findByIds = runner.findByIds?.bind(runner); - const answer = await enqueued([ - input(documentForThisCheck(1), "de"), - input(documentForThisCheck(1), "fr"), - ]); - if (!findByIds || !answer) { + it("resolves cancel on a spent handle", async (ctx) => { + const handle = await spent(); + if (!handle) { ctx.skip(); return; } - const handles = [...new Set(answer.map((assignment) => assignment.handle))]; - - const tasks = await findByIds(handles); - const locales = tasks.map((task) => localeOf(task.id, task.input.targetLng)); - const announcedOnce = new Set(locales).size; - expect(announcedOnce, "one Task per handle and target locale").toBe(locales.length); + expect( + await settle(runner.cancel([handle])), + "cancel on a spent handle resolves rather than rejecting" + ).toBe("resolved"); }); }); @@ -316,29 +449,25 @@ export function assertTaskRunnerContract( }); it("keeps only the documents the filter names", async () => { - await runner.enqueue([ - input(documentForThisCheck(1), "de"), - input(documentForThisCheck(2), "de"), - ]); + const kept = documentForThisCheck(1); + await runner.enqueue([input(kept, "de"), input(documentForThisCheck(2), "de")]); - const tasks = await runner.findByCollection(SLUG, { documentIds: ["doc-1"] }); + const tasks = await runner.findByCollection(SLUG, { documentIds: [kept] }); expect( - tasks.filter((task) => task.input.collectionId !== "doc-1"), + 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 () => { - await runner.enqueue([ - input(documentForThisCheck(1), "de"), - input(documentForThisCheck(2), "de"), - ]); + const kept = documentForThisCheck(1); + await runner.enqueue([input(kept, "de"), input(documentForThisCheck(2), "de")]); - const tasks = await runner.findByCollection(SLUG, ["doc-1"]); + const tasks = await runner.findByCollection(SLUG, [kept]); expect( - tasks.filter((task) => task.input.collectionId !== "doc-1"), + tasks.filter((task) => task.input.collectionId !== kept), "the array means what { documentIds } means" ).toEqual([]); }); 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 95d2542c0..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,17 +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 { handleOf } from "./handleOf.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"; @@ -83,16 +86,14 @@ 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); } - readonly reportsFinalFailure = true; - configure(context: TaskRunnerContext): (config: Config) => Config { const { taskName, workflowName, queueName, retries, autoRun } = this.config; const retryLimit = retryLimitOf(retries); - const { handler, collections, reportFinalFailure } = context; + const { handler, collections, report } = context; /** * What the plugin's own handler threw, kept until the run's loop can report it. * @@ -177,7 +178,6 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { }; }) => { const { collectionSlug, collectionId } = readCollectionRef(args.input); - const handle = handleOf(args.job); try { await handler( args.req.payload, @@ -188,7 +188,6 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { targetLng: args.input.target_lng, strategy: args.input.strategy, publishOnTranslation: args.input.publish_on_translation ?? false, - ...(handle === null ? {} : { handle }), }, { requester: requesterOf(args.input) } ); @@ -214,21 +213,28 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { : {}), 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; + // 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 = - reportFinalFailure && thrown.has(job) - ? owedIfGaveUp(job, taskName, target, retryLimit) - : []; - if (owed.length > 0) { - await reportFinalFailure?.(req.payload, owed, thrown.get(job)); + 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; } @@ -266,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 886b92fad..fe0ba941a 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,11 +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 { 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 { stillOwed } from "./stillOwed.js"; import { planEnqueue } from "./planEnqueue.js"; import type { RequestShape } from "./planEnqueue.js"; import { readCollectionRef } from "./readCollectionRef.js"; @@ -27,7 +30,9 @@ const assign = (request: RequestShape, targetLngs: string[], handle: string): En targetLngs.map((targetLng) => ({ collectionSlug: request.collectionSlug as CollectionSlug, collectionId: request.collectionId, + sourceLng: request.sourceLng, targetLng, + strategy: request.strategy, handle, })); @@ -52,10 +57,15 @@ 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 { const byRequest = new Map(); @@ -85,6 +95,9 @@ export class PayloadJobsTaskRunner implements TaskRunner { ); for (const entries of served) assigned.push(...entries); } + for (const assignment of assigned) { + await this.context.report(this.payload, assignment, { state: "queued" }); + } return assigned; } @@ -186,15 +199,19 @@ export class PayloadJobsTaskRunner implements TaskRunner { ); } - async findByIds(taskIds: string[]): Promise { - if (taskIds.length === 0) return []; - const jobs = await this.findRawJobs({ id: { in: taskIds } }); - return jobs.flatMap(normalizeJobLocales); - } - 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 stillOwed(normalizeJobLocales(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/lifecycle/stillOwed.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts similarity index 96% rename from packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.test.ts rename to packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts index 53c950b6e..d0d0cff0a 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/stillOwed.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import type { Task, TaskInput } from "../task-runner/types.js"; -import { stillOwed } from "./stillOwed.js"; +import type { Task, TaskInput } from "../../types.js"; +import { stillOwed } from "../stillOwed.js"; const RUN_ID = "run-1"; 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/owedIfGaveUp.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedIfGaveUp.ts index 68cdedcb4..569600d85 100644 --- 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 @@ -1,4 +1,4 @@ -import { stillOwed } from "../../lifecycle/stillOwed.js"; +import { stillOwed } from "./stillOwed.js"; import type { Task } from "../types.js"; import { isLastAttempt } from "./isLastAttempt.js"; import { normalizeJobLocales } from "./normalizeJob.js"; 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 index 779a1f912..a4911a29b 100644 --- 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 @@ -1,4 +1,4 @@ -import type { Task } from "../task-runner/types.js"; +import type { Task } from "../types.js"; /** * Which of a run's locales it has not delivered; a locale it never reached is owed too. 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 366023f36..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,7 +2,7 @@ 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 { 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"; @@ -11,12 +11,12 @@ 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; } @@ -39,12 +39,16 @@ export class SyncTaskRunner implements TaskRunner { }; this.tasks.set(key, task); - assigned.push({ + 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(); @@ -57,7 +61,7 @@ export class SyncTaskRunner implements TaskRunner { await swallowOrThrow( scope, async () => { - await this.handler( + await this.context.handler( this.payload, { collection: input.collectionSlug, @@ -66,22 +70,24 @@ export class SyncTaskRunner implements TaskRunner { targetLng: input.targetLng, strategy: input.strategy, publishOnTranslation: input.publishOnTranslation, - handle: task.id, }, 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; } - /** `findByIds` is deliberately absent: announcing a cancellation would report work that completed. */ + /** No-op, and reports nothing: every assignment settled inside `enqueue`, so none is still owed. */ cancel(_taskIds: string[]): Promise { return Promise.resolve(); } 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 index 45d731acc..2a67d0884 100644 --- 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 @@ -8,10 +8,10 @@ import { SyncTaskRunner } from "../SyncTaskRunner.js"; assertTaskRunnerContract( "SyncTaskRunner", - () => + (report) => new SyncTaskRunner( {} as Payload, - vi.fn().mockResolvedValue(undefined), + { 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 1f13c97d2..6c46f70e7 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 => ({ @@ -47,7 +53,6 @@ describe("SyncTaskRunner", () => { targetLng: "de", strategy: "overwrite", publishOnTranslation: false, - handle: assigned[0]?.handle, }, {} ); @@ -74,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]); @@ -95,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()]); @@ -112,7 +117,7 @@ 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" })]), @@ -126,7 +131,7 @@ 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" }), @@ -139,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" }), @@ -149,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 569daa354..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 @@ -63,10 +63,28 @@ export type Task = { 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 */ From dc4234dd661ff3f56ae83ca84ec1a964b698c7f8 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 9 Oct 2026 11:13:29 +0200 Subject: [PATCH 10/10] fix(translator): a run that gave up owes a cancellation nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling a run whose retries were spent announced `onCancelled` for the very locales that had just been told `onFailed` — a second ending for one locale, which the event contract says cannot happen. `stillOwed` keeps every locale that is not `completed`, and Payload only writes `completedAt` when a whole workflow succeeds, so a spent run keeps its error, keeps no completion date, and its settled locales still read as owed. The cancel path now asks its own question through `owedOnCancel`: a run that has given up owes nothing, because spending the last attempt already settled everything it had not delivered; a run merely waiting to try again owes its undelivered locales, logged failures among them. Keeping the two apart matters — the obvious narrowing, "not completed and not failed", would have silenced the legitimate case, where a job between attempts has every locale logged as failed and every one of them still owed. Reachable without doing anything unusual: `DELETE /translate/cancel-by-collection/:slug` sweeps up whatever is neither completed nor running, and a spent run is both. Covered on a real database from both the by-handle and the by-collection route, and in a unit check that pins the between-attempts case in the other direction. Also here, from the same verification pass: - **`onQueued` had no check at all.** Its criterion asked for a host registering the callback on a real database, for both runners; nothing did. Two specs now do, and a mutation proves each — reporting `queued` after the work makes the inline runner's spec read "de ended before the host was told it had started", and deleting the queued loop makes the jobs runner's spec red. - **Three places described code that no longer exists.** `TranslationTask.handle` claimed to be absent on `onQueued`; it has not been since the runner became what reports `queued`, and a host branching on its absence would have been misled. `wireTranslateRunner`'s docblock still described the deleted decoration. `DEPRECATIONS.md` pointed a reader at the removed `reportsFinalFailure` instead of at `report`'s `failed` event. - **One criterion was false and is restated.** "A runner that does not implement `report` fails to build" cannot be true: the `silent` conformance fixture never calls `report` and compiles, because no type can require that a function be called. What the required field does enforce — that nothing can build a context without it — breaks nine files when removed, and that is what the criterion, the decision behind it and the fixture's docblock now claim. --- .../cancel-after-giving-up.int.test.ts | 104 ++++++++++++++++++ ...eued-before-any-ending-on-jobs.int.test.ts | 84 ++++++++++++++ .../queued-before-any-ending.int.test.ts | 71 ++++++++++++ .../docs/DEPRECATIONS.md | 6 +- ...-10-08-one-channel-for-task-events.task.md | 61 +++++++++- .../src/__tests__/plugin.test.ts | 10 +- .../translate-document/wireTranslateRunner.ts | 4 +- .../src/server/modules/lifecycle/types.ts | 12 +- .../__tests__/TaskRunner.conformance.types.ts | 6 +- .../PayloadJobsTaskRunner.ts | 4 +- .../__tests__/owedOnCancel.test.ts | 57 ++++++++++ .../payload-jobs-runner/owedOnCancel.ts | 14 +++ .../__tests__/SyncTaskRunner.test.ts | 2 +- 13 files changed, 414 insertions(+), 21 deletions(-) create mode 100644 apps/dev/src/integration/translator/cancel-after-giving-up.int.test.ts create mode 100644 apps/dev/src/integration/translator/queued-before-any-ending-on-jobs.int.test.ts create mode 100644 apps/dev/src/integration/translator/queued-before-any-ending.int.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts create mode 100644 packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/owedOnCancel.ts 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/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/packages/payload-plugin-translator/docs/DEPRECATIONS.md b/packages/payload-plugin-translator/docs/DEPRECATIONS.md index 6fe0465e9..24bf33102 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -281,8 +281,8 @@ the single source of truth — code annotations link here by anchor instead of d - **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 reports it itself; see - `TaskRunnerProvider.reportsFinalFailure`. + 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 @@ -290,6 +290,6 @@ the single source of truth — code annotations link here by anchor instead of d - **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` (reports only when the runner does not) + - `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-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 index 48ae8f460..914ca348c 100644 --- 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 @@ -72,9 +72,14 @@ and `configure` already hands over a context object. One object beats a growing 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, not optional.** A runner that silently fails to implement it would -leave its host hearing nothing, and no compiler would say so. Required means a third-party runner -fails to build until it reports, which is the loudest available signal. +**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 @@ -108,7 +113,7 @@ the handle is the identity, and promising more would be promising what nothing n | 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 | A runner that does not implement `report` fails to build | compile-only fixture under `check-types` | removing `report` breaks the build | +| 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 | @@ -155,3 +160,51 @@ than pretending otherwise. Closing it means changing what cancellation does, whi ## 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/src/__tests__/plugin.test.ts b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts index 3253b695c..5511c322b 100644 --- a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts +++ b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from "vitest"; 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"; @@ -416,16 +418,18 @@ describe("translatorPlugin — lifecycle callbacks", () => { const payload = { logger: { error: vi.fn() } } as unknown as Payload; - it.each([ + 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" }], - ])("turns %s into %s", async (_label, callbackName, event) => { + ]; + + it.each(mappings)("turns %s into %s", async (_label, callbackName, event) => { const callback = vi.fn(); const context = await contextHandedToTheRunner({ [callbackName]: callback }); - await context.report(payload, assignment, event as never); + await context.report(payload, assignment, event); expect(callback, `${String(event)} must reach ${callbackName}`).toHaveBeenCalledWith( expect.objectContaining({ collection: "posts", id: "doc-1", targetLng: "de" }), 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 da4ea986c..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 @@ -26,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. 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 60c6a135d..9544afc3e 100644 --- a/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/lifecycle/types.ts @@ -18,9 +18,10 @@ export type TranslationTask = { */ strategy: string; /** - * The run translating this locale, as the enqueue answer named it. + * 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. * - * Absent on `onQueued`, which fires before the run exists — take it from the enqueue answer there. + * Optional because a runner written outside this package may answer an enqueue with nothing. * * @since 0.16.0 */ @@ -41,8 +42,11 @@ export type TranslationTask = { */ 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 */ diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts index a19b3a036..572986bf8 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts @@ -6,8 +6,10 @@ 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. - * Narrowing `enqueue` to require assignments must make this file fail to compile, and so must - * dropping `report` from a provider. + * + * 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; 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 fe0ba941a..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 @@ -8,7 +8,7 @@ import type { PayloadJobsRunnerConfig, PayloadJob, StoredWorkflowInput } from ". import { assignmentOf } from "./assignmentOf.js"; import { handleOf } from "./handleOf.js"; import { normalizeJobLocales } from "./normalizeJob.js"; -import { stillOwed } from "./stillOwed.js"; +import { owedOnCancel } from "./owedOnCancel.js"; import { planEnqueue } from "./planEnqueue.js"; import type { RequestShape } from "./planEnqueue.js"; import { readCollectionRef } from "./readCollectionRef.js"; @@ -204,7 +204,7 @@ export class PayloadJobsTaskRunner implements TaskRunner { // 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 stillOwed(normalizeJobLocales(job))) { + for (const task of owedOnCancel(job)) { const assignment = assignmentOf(job, task.input.targetLng); if (assignment) { await this.context.report(this.payload, assignment, { state: "cancelled" }); 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/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/sync-runner/__tests__/SyncTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts index 6c46f70e7..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 @@ -42,7 +42,7 @@ describe("SyncTaskRunner", () => { describe("enqueue", () => { it("executes handler immediately", async () => { const input = createInput(); - const assigned = await runner.enqueue([input]); + await runner.enqueue([input]); expect(mockHandler).toHaveBeenCalledWith( mockPayload,