Skip to content

Taxonomy term hierarchy uses locale-bound parent_id, causing cross-locale parent leak and silent tree flattening #1347

@MA2153

Description

@MA2153

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.

  1. Create parent term computing (en) and child term laptops (en) with parentId = computing(en). Tree in en is correct.
  2. 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.
  3. Now translate the parent: create computacion (es) as a translation of computing(en). This does not retroactively re-point portatiles(es).
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions