Skip to content

fix(translator): run auto-translate inside the save that triggered it - #141

Merged
SearheiParkhamchuk merged 1 commit into
mainfrom
fix/translator-auto-translate-transaction
Sep 15, 2026
Merged

SearheiParkhamchuk merged 1 commit into
mainfrom
fix/translator-auto-translate-transaction

Conversation

@SearheiParkhamchuk

Copy link
Copy Markdown
Contributor

Closes #124.

The defect

The auto-translate hook fires from afterChange, while the save's transaction is still open. The translation's first act is to read the document back — and that read opened a transaction of its own, where the row does not exist yet. Payload answers NotFound, the sync runner swallowed it, and nothing was translated.

SQLite and MongoDB do not isolate the uncommitted row the same way, so the whole integration suite stayed green while the feature was dead on PostgreSQL.

The failure was also completely silent: LifecycleNotifier.failed only reported through the host's onFailed callback, and a host that configured none saw nothing at all.

The fix

TransactionScope — a slice carrying the one field an operation needs to join a transaction — travels from the hook to every operation call on the path:

hook → enqueue(tasks, scope) → handler(payload, input, scope) → handle(…)
     → findByID / update / provenance upsert

Reads and writes join it. The write targets the very document the caller has not committed, so from outside the transaction there is nothing to update — measured, not assumed: an intermediate design that kept writes outside reproduced the original bug on the write instead of the read.

Passing the whole PayloadRequest was rejected twice over: it breaches the package rule against god types in leaf helpers, and createLocalReq fills its argument in place while Payload shares one request across every document of a bulk update — so the translator's skip-context would have leaked to the next document and stopped it translating. That regression now has a test.

The trade, and why it is visible

Payload's killTransaction fires from the catch of every operation and rolls back whenever a transaction id is present, without checking whose transaction it is. So a translation Payload rejects takes the editor's save with it, and the first version of this change reported success over a save that had already been thrown away.

killedTheCallersTransaction now rethrows such a failure. It cannot save the edit — that is already gone — it only stops the plugin claiming otherwise. Narrowed to APIError, because a provider outage throws before any operation runs and leaves the save intact.

Both halves are pinned by integration tests on PostgreSQL:

failure save other locales
provider unreachable commits still translated
Payload rejects the translated value fails visibly

Deliberately outside the transaction

  • The jobs runner. Its job runs after the commit and never had this defect; joining would add the killTransaction exposure to buy only the removal of an orphan job row.
  • The provenance cleanup on document delete. A failed sidecar delete must never roll back the delete that triggered it — a prior decision, with its own test.

The provenance receipt does join, or it would outlive the translation it certifies and report a document translated at a fingerprint that never committed.

Not a breaking change

Both new parameters are optional, so a third-party TaskRunner still satisfies the interface. TaskRunnerProvider.create is untouched.

Verification

  • Tests written against the contract and run red first: 8 red on untouched source, all with assertion mismatches that reached the unit.
  • Every new guard was proved by mutation — including the bulk-publish case, which reddens as soon as the live request travels uncopied, and the rollback case, which reddens with an empty scope.
  • Unit 1514 · check-types clean in both packages · lint at the 58-warning repo baseline.
  • Integration green on SQLite, PostgreSQL and MongoDB, in both queue modes — 6 of 6 runs.

Design record, including the abandoned reads-only variant and why it cannot work: packages/payload-plugin-translator/docs/plans/2026-09-14-auto-translate-in-the-triggering-transaction.task.md.

An auto-translation now joins the transaction of the save that triggered it, so the
two commit or roll back together. Before this, auto-translate did nothing at all on
PostgreSQL.

The hook fires while the save's transaction is still open, and the translation's
first act is to read the document back. That read opened its own transaction, where
the row did not exist yet, so Payload answered "Not Found" and the translation
stopped. SQLite and MongoDB do not isolate the uncommitted row the same way, which
is why the suite stayed green.

The transaction travels as `TransactionScope`, a slice carrying the one field an
operation needs to join it, from the hook to every operation call on the path.
Passing the whole request instead would have dragged a god type through every
handler and risked `createLocalReq` mutating a request Payload shares across every
document of a bulk update. Reads and writes both join it: the write targets the very
document the caller has not committed, so from outside there is nothing to update.

A failure inside that transaction is now surfaced instead of swallowed. Payload's
`killTransaction` fires from the catch of every operation and rolls back whenever a
transaction id is present, without checking whose it is — so a translation Payload
rejects takes the editor's save with it. Rethrowing cannot save the edit, which is
already gone; it stops the translator reporting a save that did not happen. Narrowed
to `APIError`, because a provider outage throws before any operation runs and leaves
the save intact.

The provenance receipt joins the same transaction, or it would outlive the
translation it certifies and report a document translated at a fingerprint that
never committed. Two writes deliberately stay outside: the jobs runner, whose job
runs after the commit and never had this defect, and the provenance cleanup on
document delete, which must never roll back the delete that triggered it.

`LifecycleNotifier` now logs a failed translation when the host configured no
`onFailed` callback. Without it this defect left no trace anywhere, which is most of
why it survived so long.

Closes #124
@vercel

vercel Bot commented Sep 15, 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 Sep 15, 2026 10:19am UTC

Request Review

@SearheiParkhamchuk
SearheiParkhamchuk merged commit a4fe19c into main Sep 15, 2026
2 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.13.2 🎉

The release is available on npm package (@latest dist-tag)

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

translator: auto-translate does nothing on Postgres — the hook's work runs outside the triggering transaction

1 participant