Skip to content

[upstream #5007] fix(core): Prevent duplicate translations from concurrent saves - #5

Open
ayim wants to merge 3 commits into
masterfrom
mirror/upstream-5007
Open

[upstream #5007] fix(core): Prevent duplicate translations from concurrent saves#5
ayim wants to merge 3 commits into
masterfrom
mirror/upstream-5007

Conversation

@ayim

@ayim ayim commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Mirror of vendurehq#5007.

Original author: @brmk
Original head SHA: 4ad3786017cec80dad33e1a33470ccb30caffdb5
Original target: master


Description

Translatable entities (Product, Collection, ProductVariant, etc.) could end up with two translation rows for the same (baseId, languageCode) pair. TranslatableSaver.update() loads existing translations, then TranslationDiffer inserts any language not already present — with no locking and no unique constraint. Two concurrent updates that both add the same new language (e.g. a double-click on Update in the Admin UI) can both read "no en row yet" before either commits, and both insert one. Once that happens, the entity becomes uneditable through the Admin UI.

This PR:

  • Adds @Unique(['languageCode', 'base']) to all 13 translation entities, so the database itself now rejects a second row for the same (baseId, languageCode) pair.
  • Adds isUniqueConstraintViolationError() to db-errors.ts (Postgres/MySQL/SQLite), mirroring the existing isForeignKeyViolationError().
  • Updates TranslationDiffer.applyDiff() so that when an insert hits this constraint (a concurrent request already inserted the row), it re-fetches that row and updates it instead of throwing a 500 — so a losing concurrent save converges instead of leaving the entity stuck.
  • Adds deduplicateTranslations() to migration-utils, a helper that accepts a single table name or an array of table names, which users can call from their own migration's up() (before the new unique constraint is created) to remove any duplicate rows that already exist in their database. It also works for any custom translatable entity a plugin defines, not just the 13 core ones.
  • Adds an e2e regression test reproducing the race (two concurrent updateProduct calls adding the same new language) and asserting only one translation row results.

Fixes vendurehq#4884

Breaking changes

Adds a unique index to the 13 *_translation tables. This only affects databases where duplicate rows already exist for the same (baseId, languageCode) — in that case, running vendure migrate generate will produce a migration that fails on up() until the duplicates are removed. No changes to any public API surface otherwise (deduplicateTranslations is a new, additive export).

Migration guide for users

Since existing production databases may already contain the duplicate rows this PR guards against, upgrading needs one extra manual step — this should be called out in the release notes:

  1. Upgrade @vendure/core and run vendure migrate generate <name> as usual. If your database has no duplicates, the generated migration only adds the new constraints and needs no further changes.
  2. If the generated migration fails when applied (unique constraint violation on up()), open the generated migration file and add a deduplicateTranslations(queryRunner, [...]) call at the very top of up(), listing every *_translation table touched by that migration (see the JSDoc example on deduplicateTranslations in migration-utils/translation-deduplication.ts for a full worked Postgres example with the exact, deterministic constraint names TypeORM will generate). If you have your own plugins with custom translatable entities, include their translation tables in that same array.
  3. Re-run the migration. deduplicateTranslations keeps the most recently updated row per (baseId, languageCode) and removes the rest, so no translation content is lost — only the redundant duplicate rows are dropped.

Worth adding to the upgrade guide / release notes so users hitting the failed-migration case aren't stuck guessing what to do.

Checklist

📌 Always:

  • I have set a clear title
  • My PR is small and contains a single feature
  • I have checked my own PR

👍 Most of the time:

  • I have added or updated test cases
  • I have updated the README if needed

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duplicate translations (same entity + languageCode) created by concurrent/double-click Admin save; entity then becomes uneditable

2 participants