Skip to content

feat(translator): carry a run's handle through the lifecycle, and say it once - #177

Open
SearheiParkhamchuk wants to merge 11 commits into
mainfrom
feat/translator-lifecycle-run-context
Open

SearheiParkhamchuk wants to merge 11 commits into
mainfrom
feat/translator-lifecycle-run-context

Conversation

@SearheiParkhamchuk

Copy link
Copy Markdown
Contributor

The plugin had a handle for every run it queued and threw it away. Four issues from one consumer
building an editorial workflow on top of it reduce to that.

Closes #107, closes #110, closes #106, closes #103.

Replaces #175, which was closed once the work grew past what its description covered.

What a host gets

enqueue answers with what it arranged — one entry per requested target locale, each naming
the handle that will run it. Per locale rather than per handle, because one job row covers a
document's whole locale list. A locale an existing run already covers is answered too, with that
run's handle.

Every lifecycle callback carries that handle, onQueued included, so a host can join a
callback back to the request behind it without keeping its own index.

onFailed now means the locale will not be translated. It fired on every failed attempt, so
"failed" did not mean failed and the consumer ran with retries disabled. When a run gives up it
reports every locale it still owed, not only the one that threw.

Cancelling says what it stopped, once per locale the run still owed, before the row is deleted.

Collection status names the document and locale beside the handle.

One channel, owned by the runner

Three places used to tell the host what happened, with a boolean picking between two of them. The
runner now owns the whole story: it is handed report 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 stays the only thing that
knows a host exists.

What changed after the first review round

Four further passes, each with its own contract under docs/plans/ and its own verification.

The stored job input is declared once. Its field vocabulary lived in six places — four
TypeScript shapes and two Payload Field[] literals — with nothing checking any against any
other. A zod schema is now the only declaration; the types are inferred and the Field[] is
generated. The generator reads only zod's public surface, because this repository carries both
zod 3 and zod 4 and their internals differ. A characterisation test pins the generated fields
against a baseline taken before any edit — and caught, within the hour, that z.coerce.string()
turns undefined into the string "undefined".

The runner's folder says what each file is for. Sixteen entries in one listing became store/
— the shape of what Payload stores and the readers that turn it into ours — and model/, the
rules that decide. The dependency runs one way, and the convention is written into the package's
CLAUDE.md.

Adding something to a host's config has one name. contribute handles a field that may be
absent, a list, or a function producing one — Payload declares jobs.autoRun all three ways — and
chain does the same for onInit. That closed a defect: applying the plugin twice registered the
task and the workflow twice and polled the queue twice per tick, which Payload does not catch.

The workflow handler says what it does. Two of its four unnamed mechanics got names:
localesAsTheyStand, which re-reads the locale list on every step so a locale appended to a run in
flight is picked up, and deliveredLocales, a snapshot taken before anything runs because Payload
writes a task's succeeded entry during the call.

Breaking, and for whom

For a runner implemented outside this package: create takes the context rather than the handler,
report is required, and several removals are compiler errors. All of it predates any release.

For a host that merely configures the plugin: nothing. The callbacks, their arguments, the runner
factories and every HTTP response are as they were, and the published barrel src/index.ts is
byte-identical to main.

Known gaps, not fixed here

  • Cancelling does not stop a locale already being translated: it is announced cancelled and still
    finishes. cancel is byte-identical to main, so this predates the change.
  • 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. Written into the contract as a limit.
  • thrown is a WeakMap keyed by the job row, and nothing checks that the task handler and the
    workflow loop see the same object.

Verification

Check Result
unit 2057 passed, 148 files
integration — SQLite 224 passed / 7 skipped
integration — PostgreSQL 229 passed / 2 skipped
integration — MongoDB 222 passed / 9 skipped
check-types, every package clean
declaration build (tsc) green
analyzer delta vs the base nothing introduced
host's generated payload-types.ts byte-identical

Each adapter was run with and without Payload's exclusive queue. Run against the pinned Payload
3.90.1; the places in Payload's own source this reasons about were read against that version.

Eight review passes in total — deep, complexity, abstraction, comment and red-test audits, plus
three rounds of correctness, regression and intent review. Every finding is recorded with its
resolution in the ## Review log of the contract it belongs to, under docs/plans/.

`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.
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.
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.
`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.
…tually mean

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.
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.
…ot need

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.
`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.
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.
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.
…nner does

Four passes over the Payload-jobs runner, each verified against a real database before the next
began.

**The stored job input is declared once.** The field vocabulary lived in six places — four
TypeScript shapes and two Payload `Field[]` literals — and nothing checked any against any other,
so adding a field was six edits and forgetting one was silent. A zod schema in
`store/jobInput.schema.ts` is now the only declaration: the types are inferred from it and the
`Field[]` Payload is registered with is generated from it. The generator reads only zod's public
surface, because this repository already carries both zod 3 and zod 4 and their internals differ —
`_def.typeName` is a string on one and `undefined` on the other. A characterisation test pins the
generated fields against a baseline captured before any edit, and earned its keep within the hour
by catching that `z.coerce.string()` turns `undefined` into the string `"undefined"`.

**The runner's folder says what each file is for.** `payload-jobs-runner/` had sixteen entries in
one listing. It is now `store/` — the shape of what Payload stores, and the readers that turn it
into ours — and `model/` — the rules that decide. The dependency runs one way; nothing in `store/`
knows `model/` exists. The convention is written down in the package's `CLAUDE.md`.

**Adding something to a host's config has one name.** `contribute` takes a field that may be
absent, a list, or a function producing one — Payload declares `jobs.autoRun` all three ways — and
adds this plugin's entries without repeating what the host already put there. For the function
form the duplicate check travels with the wrapper, because what that function will return cannot
be known at config time. `chain` does the same for `onInit`. Four hand-rolled blocks became four
calls.

**That closed a defect on the way.** Applying the plugin twice registered the task and the
workflow twice and added a second `autoRun` entry for the same queue, so `translations` was polled
twice per tick. Payload does not catch it: it gathers slugs into a `Set` only to validate, and
throws only when a task slug collides with a workflow's.

**And the workflow handler says what it does.** It interleaved four unnamed mechanics in twenty
lines. Two now have names: `localesAsTheyStand`, whose whole point is that it re-reads the locale
list on every step so a locale appended to a run in flight is picked up, and `deliveredLocales`, a
snapshot taken before anything runs because Payload writes a task's `succeeded` entry during the
call.

Two readers also stopped walking properties by hand: `readCollectionRef` and the two structural
guards over a job's error now parse against the schema, with their own tests unedited.

Unit 2057 passing across 148 files, integration 224 passing and 7 skipped on SQLite, `check-types`
clean across the workspace, the real `tsc` declaration build green, the analyzer gaining nothing,
and the host's generated `payload-types.ts` byte-identical to what it was.
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ideal-cms Ready Ready Preview Oct 9, 2026 5:12pm UTC

Request Review

This branch was successfully deployed

1 active deployment
Preview — e1b4086e Deployed Oct 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant