Skip to content

fix(core): share taxonomy hierarchy and collections across locales - #3360

Merged
ascorbic merged 15 commits into
emdash-cms:mainfrom
danielmlr:feat/taxonomy-defs-group-owned
Sep 25, 2026
Merged

ascorbic merged 15 commits into
emdash-cms:mainfrom
danielmlr:feat/taxonomy-defs-group-owned

Conversation

@danielmlr

@danielmlr danielmlr commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Stores a taxonomy's hierarchical and collections once per taxonomy instead of on each locale's definition. Each locale's definition held its own copy, so writes let translations drift apart and a locale without a definition of its own listed no terms.

A new table keyed by taxonomy name (migration 085) holds both fields, so a definition created in a new locale joins the existing taxonomy, and one that sends hierarchical or collections other than a read returns is rejected; labels stay per locale. Every write path changes the fields for all locales, including a seed that replaces an unedited built-in category or tag definition (#3355) from any locale, and an update writes only the fields it sends. A seed translation may omit both fields and takes them from the entry its same-name translationOf chain ends at, which applies first wherever it sits in the file, so the translation's terms follow its structure; validation rejects a chain that loops and warns when entries disagree on the values. The migration merges drifted rows without hiding anything: hierarchical if any locale was, collections as the union. A site import rebuilds each taxonomy's fields from its imported definitions the same way, in a new step of its rebuild stage, and the transfer format classifies the new table as derived. The per-locale columns stay in sync for direct readers such as the sandbox bridges, except after importing a package whose locales disagreed: the import's verify stage compares definition rows with the package, so they keep the package's values until the next structure write. Public reads cache every locale's definitions in one query, so logged-out query counts drop on term and post pages on SQLite and stay level on D1.

The changeset lists each behavior change, under minor because major isn't accepted before 1.0. #1928 also adds migrations, and #3025 edits the seed-apply branch rewritten here.

Part of #2525.

Closes #2962

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5.5

Screenshots / test output

Not applicable (no UI change). The new tests in taxonomy-def-structure.test.ts failed before the change, except the create that sends back a read's values, which keeps what main allowed; they and the migration tests pass on SQLite and PostgreSQL, and tests/workerd/migrations-d1.test.ts passes on D1. The site import cases in transfer/taxonomy-structure.test.ts fail without the rebuild step and pass on SQLite and PostgreSQL, and the transfer tests in tests/workerd pass on D1. The built-in cases in seed-on-conflict.test.ts fail without the structure write, the one that lists a translation first also when entries apply in file order, and the chain cases in the validation tests fail when only one translationOf step is followed; the ones that use a database run on SQLite only.

@changeset-bot

changeset-bot Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5eae228

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
emdash Minor
@emdash-cms/plugin-test Patch
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the query-count changed PR diff modifies query-count snapshot files label Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Query-count snapshot changes

6 routes changed, total Δ -7 queries.

SQLite

Route Before After Δ
GET /category/development (cold) 12 11 -1
GET /category/development (warm) 11 10 -1
GET /posts/building-for-the-long-term (cold) 17 16 -1
GET /posts/building-for-the-long-term (warm) 17 15 -2
GET /tag/webdev (cold) 11 10 -1
GET /tag/webdev (warm) 11 10 -1

Comparing snapshot files between base and head. Updated automatically on each push.

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,661 lines across 47 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@3360

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@3360

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@3360

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@3360

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@3360

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@3360

emdash

npm i https://pkg.pr.new/emdash@3360

create-emdash

npm i https://pkg.pr.new/create-emdash@3360

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@3360

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@3360

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@3360

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@3360

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@3360

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@3360

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@3360

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3360

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@3360

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@3360

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@3360

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@3360

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@3360

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@3360

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@3360

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@3360

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@3360

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@3360

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@3360

commit: 5eae228

@github-actions

Copy link
Copy Markdown
Contributor

Each locale's taxonomy definition stored its own `hierarchical` and
`collections`, and nothing kept the copies in agreement. A translation
could apply the taxonomy to different collections or flatten its term
tree, and a locale without its own definition row got no terms at all.

The structure now lives once per taxonomy name in
`_emdash_taxonomy_def_groups` (migration 084); `label` and
`labelSingular` stay per locale. Writes through any locale change the
structure for every locale. A definition created for a name that already
exists in another locale takes the taxonomy's structure and rejects
differing values, so a create cannot silently reset it. A seed
translation takes the structure from the entry it translates, wherever
it appears in the file, and a seed that replaces an unedited built-in
`category` or `tag` definition replaces its structure too. The migration
merges drifted definitions without hiding anything: hierarchical if any
locale was, collections as the union. Definitions of different names
that shared a translation group, which older seed files and API versions
could create, get one group per name. The per-locale columns keep a copy
for code that reads them directly, such as the plugin sandbox bridges.

Public reads load every locale's definitions once per isolate and
resolve labels in memory, so warm renders no longer walk the locale
chain per taxonomy.
@danielmlr
danielmlr force-pushed the feat/taxonomy-defs-group-owned branch from bcd1bbf to a7503f0 Compare September 24, 2026 11:56
@danielmlr
danielmlr marked this pull request as ready for review September 24, 2026 13:05
@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Sep 24, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the shared taxonomy structure / cross-locale collections PR (#3360) by examining the migration, the taxonomy-def repository, the taxonomy API handlers, seed apply/validate, WXR import, plugin context, the Astro loader, and the plugin-test fixture.

Overall the implementation is coherent and the migration is idempotent: _emdash_taxonomy_def_groups owns hierarchical / collections, per-locale definition rows keep mirrored copies, saveTaxonomyStructure links every row of a name to the same group, and reads fall back through the locale chain. The create/update handlers correctly reject attempts to give one locale a different structure than the rest of the taxonomy, and seed/WXR import mirror the new ownership model.

I found one non-blocking edge case in seed validation: the omission allowance for hierarchical/collections only looks one hop through translationOf, so a chain of translations can all omit the structure and validation still passes even though no entry provides it. The apply engine would then fall back to defaults, which is benign but inconsistent with the intended rule. Recommend resolving the chain to the root source or requiring one entry in the chain to carry the values.

Comment thread packages/core/src/seed/validate.ts
@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Sep 24, 2026
…ntry

A taxonomy entry that omits `hierarchical` and `collections` took them
from the entry its `translationOf` names, one step only. When a
translation of a translation came first in the file, it wrote the
defaults, flat and without collections, for the whole taxonomy, and
the declaring entry applied later in skip mode kept them.

Apply and validation now follow `translationOf` through entries of the
same name to the last one. Validation rejects an omitted structure when
that chain loops, and checks term parents against the hierarchy the
apply step will use.
@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Sep 24, 2026
The helper's comment named only the case where a translationOf chain leaves the taxonomy's name; most chains end at an entry without translationOf.
A translation listed before its source entry applied its terms with the
taxonomy's stored structure. When the source entry then replaced that
structure, as it does for an unedited built-in category or tag and under
onConflict "update", the translation's term tree stayed flat, even when
the translation declared the hierarchy itself.

Applying a seed now takes the entries that declare their taxonomy's
structure first. Validation warns when a translation declares values
other than the ones it takes, or when two entries that declare one
taxonomy's structure disagree, since only one of them applies.
Every structure write rewrote both hierarchical and collections from a
read taken before the write. Two concurrent updates that each sent one
field could lose one of them, and a create that joined an existing
taxonomy could revert an update made in between.

saveTaxonomyStructure takes the fields to overwrite: an update passes
only the fields it received, and a create never overwrites an existing
group. Definition rows copy the group's stored values instead of the
caller's.
…hangeset

A locale without its own definition now takes one by fallback, so the
guide no longer says every locale has one. The changeset's upgrade note
separates the required action from the explanation.
Main added 084_site_transfer. Two migrations with one number would sort
the taxonomy group migration before one a site may already have run.
…group-owned

# Conflicts:
#	packages/core/src/database/migrations/runner.ts
A site import writes taxonomy definition rows directly and clears the
target's scaffold definitions, but not their structure groups. Reads
prefer the group, so an imported category or tag showed the target
scaffold's hierarchical and collections, and a taxonomy only the
scaffold had left a group that a later create adopted.

The import's rebuild stage gains a taxonomies step: each name's group is
rebuilt from its imported definitions with the migration's rule
(hierarchical if any is, every collection any lists), and groups without
a definition are deleted. The definition rows keep the package's values,
which the verify stage compares. The groups table is classified as
derived for the transfer format.
…eturns

Creating a definition in a new locale compared the request's collections
with the stored list, while reads drop collections that no longer exist
and the create rejects them. When the stored list named a missing
collection, such as the built-in category's posts on a site without
posts, every create that sent collections failed. The comparison now
uses the collections a read returns.
… locale

The built-in category and tag definitions carry locale en. A seed
applied on a site whose default locale is another looked only at its own
locale, found no definition there, and kept the built-in structure, so
the seed's hierarchical and collections were dropped for every locale.

A source entry now also rewrites the structure when every definition of
its taxonomy is an untouched built-in. Which built-ins are untouched is
read once before the entries apply, since a structure write changes the
built-in rows of every locale.
Export linked a definition to the first one of its translation group.
Rows of two taxonomy names can share a group, as a site import of an
older package leaves them, and the second name's entry then pointed at
the first name's and omitted its structure, which validation rejects.
Every locale of a name is one taxonomy, so export links by name.
…cement

Taxonomy entries of one name apply their structure's source first, so
only terms need to follow the term they translate. A built-in category
or tag replaced from another locale is described as well.
@danielmlr danielmlr added this to the 1.0 milestone Sep 24, 2026
@danielmlr danielmlr added the bot:review Trigger an emdashbot code review on this PR label Sep 24, 2026
@danielmlr danielmlr removed this from the 1.0 milestone Sep 24, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 24, 2026
@danielmlr danielmlr added the bot:review Trigger an emdashbot code review on this PR label Sep 24, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-review confirms the PR's approach is sound and the prior finding is resolved. Moving taxonomy hierarchical and collections to _emdash_taxonomy_def_groups (one row per name), mirroring the values onto per-locale definition rows, and resolving them through selectTaxonomyDefs is a clean fit for EmDash's per-locale translation model and fixes the reported drift (#2962 / #2525).

What I checked:

  • Prior finding: The seed validation/apply one-hop translationOf issue is fixed. findTaxonomyStructureSource in packages/core/src/seed/validate.ts now walks the full same-name chain to its root, rejects loops, and warns when entries disagree. apply.ts orders entries that declare their own structure first, so a translation's terms follow the structure the root entry writes regardless of file order. The new unit and integration tests cover the chain cases.
  • Migration 085: Idempotent, merges drifted rows defensively (hierarchical if any locale was, collections as the union), preserves old per-locale columns, and disentangles names that shared a translation group.
  • Write paths: API create/update, MCP tools, seed apply, WXR import, plugin-test fixture, and transfer rebuild all route structure writes through saveTaxonomyStructure, keeping every locale's row and the new group in sync. Cache invalidation (invalidateTaxonomyDefsCache) is called on every write path.
  • Read paths: Public getTaxonomyDefs/getTaxonomyDef/getTaxonomyTerms now resolve through the group table; the isolate + request cache keeps the logged-out hot path at one query. Query-count snapshots correctly drop on SQLite for category/post/tag pages and stay flat on D1, matching the PR claim.
  • Authorization and API envelope: Route wrappers still check taxonomies:read/taxonomies:manage; handler errors remain ApiResult-shaped.
  • Transfer format: _emdash_taxonomy_def_groups is correctly classified as derived/non-portable; the verify stage preserves the imported per-locale definition rows until the next structure write, as documented.
  • Changeset and docs: The changeset is detailed, migration guidance is present, and the updated taxonomies/seed/i18n/OpenAPI/MCP docs accurately describe the new behavior.

No blocking issues remain. The implementation is coherent and the regression tests are in place.

@emdashbot emdashbot Bot added review/approved Approved; no new commits since and removed bot:review Trigger an emdashbot code review on this PR review/needs-rereview New commits since the last review labels Sep 24, 2026
@ascorbic
ascorbic merged commit 6f83886 into emdash-cms:main Sep 25, 2026
52 checks passed
@danielmlr
danielmlr deleted the feat/taxonomy-defs-group-owned branch September 25, 2026 10:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core area/docs cla: signed overlap query-count changed PR diff modifies query-count snapshot files review/approved Approved; no new commits since size/XL

Projects

None yet

2 participants