Description
Taxonomy term hierarchy (taxonomies.parent_id) is encoded as a row id, which is locale-bound, while every other relationship in the taxonomy i18n model keys off a locale-agnostic value (the taxonomy name string, or a translation_group). As a result, a child term can permanently end up parented to a term row in a different locale, which silently flattens the per-locale term tree with no error.
The asymmetry
| Relationship |
Stored as |
Locale semantics |
| Term → taxonomy def |
taxonomies.name (string) |
locale-agnostic |
| Term → content entry |
content_taxonomies.taxonomy_id = translation_group |
locale-agnostic |
| Term → parent term |
taxonomies.parent_id = row id |
locale-bound |
A parent_id points at one term row, which lives in one locale. The hierarchy only holds together if every child's parent_id points at a parent row in the same locale, but nothing enforces that. validateParentTerm checks parent.name === taxonomyName (same taxonomy name), not same locale, so a cross-locale parent passes validation.
The only thing aligning locales is one best-effort block in handleTermCreate (packages/core/src/api/handlers/taxonomies.ts, ~L526–540): when creating a term translation, it resolves the source's parent translation_group and tries to swap in the same-locale sibling. It only fires when the translated parent already exists, and it only fixes the row being created — never existing children.
Impact
- The per-locale tree silently flattens:
handleTermList filters terms by locale, then buildTree only links a child if its parentId is in that locale's set (taxonomies.ts ~L101–107). A child whose parent_id references another locale's row is pushed to roots and appears top-level, with no error.
- The broken edge is permanent: nothing re-points existing children when a parent translation is created later. No backfill exists.
Steps to reproduce
Assume a hierarchical taxonomy category with default locale en and a second locale es.
- Create parent term
computing (en) and child term laptops (en) with parentId = computing(en). Tree in en is correct.
- Translate the child first:
POST /_emdash/api/taxonomies/category/terms/laptops/translations with { "locale": "es", "slug": "portatiles" }. Because the es translation of computing does not exist yet, the re-pointing guard finds no same-locale parent, so portatiles(es).parent_id is set to computing(en) — a cross-locale edge.
- Now translate the parent: create
computacion (es) as a translation of computing(en). This does not retroactively re-point portatiles(es).
GET /_emdash/api/taxonomies/category/terms?locale=es. Expected: computacion with child portatiles. Actual: portatiles appears as a root (its parent_id still references the en row, which is absent from the es-filtered set), so the Spanish tree is flattened.
There is no error or warning at any step.
Suggested fix (root cause)
Store the parent's translation_group in parent_id instead of a row id — mirroring how content_taxonomies.taxonomy_id already works. Then findChildren / buildTree resolve parent_group + locale → row, translating a parent automatically adopts existing children in every locale, and the imperative re-pointing block plus its ordering hazard can be removed. This is a forward-only schema migration (change parent_id semantics + backfill existing values through translation_group) with backwards-compat implications, so it likely warrants a Discussion on the migration path.
Environment
- emdash version: latest
main (observed on commit a6e8a918)
- Runtime: Node / SQLite and Cloudflare D1 (logic is dialect-independent; affects both)
- Area:
packages/core — taxonomy i18n
Relevant files:
packages/core/src/api/handlers/taxonomies.ts (validateParentTerm, handleTermCreate re-pointing, buildTree)
packages/core/src/database/repositories/taxonomy.ts (findByName, findChildren, parent_id handling)
packages/core/src/database/migrations/036_i18n_menus_and_taxonomies.ts (i18n schema)
Description
Taxonomy term hierarchy (
taxonomies.parent_id) is encoded as a row id, which is locale-bound, while every other relationship in the taxonomy i18n model keys off a locale-agnostic value (the taxonomynamestring, or atranslation_group). As a result, a child term can permanently end up parented to a term row in a different locale, which silently flattens the per-locale term tree with no error.The asymmetry
taxonomies.name(string)content_taxonomies.taxonomy_id=translation_grouptaxonomies.parent_id= row idA
parent_idpoints at one term row, which lives in one locale. The hierarchy only holds together if every child'sparent_idpoints at a parent row in the same locale, but nothing enforces that.validateParentTermchecksparent.name === taxonomyName(same taxonomy name), not same locale, so a cross-locale parent passes validation.The only thing aligning locales is one best-effort block in
handleTermCreate(packages/core/src/api/handlers/taxonomies.ts, ~L526–540): when creating a term translation, it resolves the source's parenttranslation_groupand tries to swap in the same-locale sibling. It only fires when the translated parent already exists, and it only fixes the row being created — never existing children.Impact
handleTermListfilters terms by locale, thenbuildTreeonly links a child if itsparentIdis in that locale's set (taxonomies.ts~L101–107). A child whoseparent_idreferences another locale's row is pushed torootsand appears top-level, with no error.Steps to reproduce
Assume a hierarchical taxonomy
categorywith default localeenand a second localees.computing(en) and child termlaptops(en) withparentId= computing(en). Tree inenis correct.POST /_emdash/api/taxonomies/category/terms/laptops/translationswith{ "locale": "es", "slug": "portatiles" }. Because theestranslation ofcomputingdoes not exist yet, the re-pointing guard finds no same-locale parent, soportatiles(es).parent_idis set tocomputing(en) — a cross-locale edge.computacion(es) as a translation ofcomputing(en). This does not retroactively re-pointportatiles(es).GET /_emdash/api/taxonomies/category/terms?locale=es. Expected:computacionwith childportatiles. Actual:portatilesappears as a root (itsparent_idstill references theenrow, which is absent from thees-filtered set), so the Spanish tree is flattened.There is no error or warning at any step.
Suggested fix (root cause)
Store the parent's
translation_groupinparent_idinstead of a row id — mirroring howcontent_taxonomies.taxonomy_idalready works. ThenfindChildren/buildTreeresolveparent_group + locale → row, translating a parent automatically adopts existing children in every locale, and the imperative re-pointing block plus its ordering hazard can be removed. This is a forward-only schema migration (changeparent_idsemantics + backfill existing values throughtranslation_group) with backwards-compat implications, so it likely warrants a Discussion on the migration path.Environment
main(observed on commita6e8a918)packages/core— taxonomy i18nRelevant files:
packages/core/src/api/handlers/taxonomies.ts(validateParentTerm,handleTermCreatere-pointing,buildTree)packages/core/src/database/repositories/taxonomy.ts(findByName,findChildren,parent_idhandling)packages/core/src/database/migrations/036_i18n_menus_and_taxonomies.ts(i18n schema)