Skip to content

feat(taxonomies): let terms carry a manual order - #2353

Merged
ascorbic merged 14 commits into
emdash-cms:mainfrom
MA2153:feat/sortable-taxonomy-terms
Aug 9, 2026
Merged

feat(taxonomies): let terms carry a manual order#2353
ascorbic merged 14 commits into
emdash-cms:mainfrom
MA2153:feat/sortable-taxonomy-terms

Conversation

@MA2153

@MA2153 MA2153 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds manual ordering for taxonomy terms, so the order a taxonomy renders in on a site is no longer tied to term labels.

Schema. Migration 056_taxonomy_term_sort_order adds taxonomies.sort_order and mints a position for every existing term. Groups are numbered by (label, id) — the order term listings used before — taking each group's label from its lowest row id, which is the locale the term was first written in.

That locale's rendered order is unchanged the moment the migration runs. Every other locale is re-sorted to match it. This is inherent to a shared position rather than a wrinkle in the backfill: one number per translation group means one collation wins, and no ordering of the numbers can preserve two different alphabets at once. A site with terms in both English and French keeps its English order and sees its French listing re-sorted into English alphabetical order, until someone reorders it. The changeset says so in the same words, since that is an upgrade-visible change.

What also changes is what happens next: a term created afterwards appends to the end of its group instead of slotting in alphabetically.

Terms are read in (sort_order, label, id) order by the repository, the runtime taxonomy helpers, and both plugin-sandbox bridges (cloudflare, workerd).

A position belongs to a term, not to a row. sort_order is per translation_group: every row of a group carries the same value, so a term sits in the same place in every locale it is translated into. This mirrors parent_id, which already stores the parent's translation_group rather than a row id.

That is the decision this PR turns on, and it is deliberate — see Rejected alternatives for the per-locale design and why it wasn't taken. In exchange for the constraint, the edge cases that come with per-locale positions disappear: there is no rule for placing a translation (it is the term, at the term's position), a half-translated locale can't produce gaps or duplicate positions, and positions within a group are always a clean 0..n-1 permutation.

Sibling groups are keyed on the raw parent_id column throughout — placement, reordering, and the migration all use the same definition. parent_id is locale-agnostic, which is what lets one position mean the same thing everywhere.

API. POST /_emdash/api/taxonomies/{name}/reorder with { parentId?, ids }, gated on the existing taxonomies:manage permission and the standard CSRF header. The body schema is .strict(), so an unknown key is a 400 rather than a silent no-op. There is no locale parameter — a position has no locale. ids are row ids or translation groups and resolve to the latter.

ids may be a subset of the group: the terms listed are permuted within the positions they already occupy, and every other member keeps its place. This is required rather than convenient — a locale renders only the terms translated into it, so a caller often cannot name every member of a group. It is also strictly safer than demanding exact membership: a term the caller never saw simply doesn't move, so a stale list can't bury it. An id outside the group is REORDER_MISMATCH (400).

The subset contract has a consequence worth naming, and the REST reference now spells it out: because omitted terms hold their absolute positions, a one-step move in a partial list can carry a term past siblings that list didn't include. With a full group of [A, B, C] and a caller that can only see [A, C], asking for ["C", "A"] yields [C, B, A] — correct for the caller, but a two-place move in any listing that does show B. There is no way around this that keeps subset reorders working; the fix is that it's documented rather than discovered.

Reordering never reparents; changing a parent is still a term update, and it moves the whole translation group to the end of the group it lands in.

Writes are batched, not looped. reorder applies positions with one CASE statement per 32 translation groups rather than one UPDATE per group. Two reasons, both D1:

  • Atomicity. D1 has no transactions, so withTransaction runs its callback bare there (documented in transaction.ts). A per-group loop that failed partway would leave a sibling group half-permuted. A swap — two changed groups, which is what the admin's carets issue — now cannot tear. Only a renumbering wide enough to span chunks can, and a torn write leaves ties, which the next reorder renumbers away.
  • Subrequests. The tie-repair path renumbers a whole sibling group, which can be larger than the 100-id cap on a request. That was one subrequest per member against a Worker's budget; it is now one per 32.

32 groups per statement is the same arithmetic the migration uses: three bound parameters each (a CASE WHEN/THEN pair plus one IN slot), inside D1's 100-parameter ceiling. The migration keeps its own copy of the helper rather than importing a shared one — a migration has to stay frozen, and a shared helper would let a later edit rewrite history for anyone mid-upgrade.

Admin UI. Up/down carets per term row on the Taxonomies screen, disabled at the ends of a group. The list updates optimistically so a row moves on click rather than after the round trip, and repeated clicks queue via a mutation scope instead of racing.

A term whose parent has no row in the current locale is rendered at the top level so it isn't lost, but it belongs to its parent's group — one this locale can't fully show. Its carets are disabled, and it is left out of the ids sent for the group it's drawn in, rather than silently addressing the wrong group. The optimistic update permutes the movable rows within the slots they occupy, mirroring exactly what the server does.

Those disabled carets explain themselves rather than reading as two dead buttons on an arbitrary row: the accessible name carries the reason ("Move X up — unavailable, its parent has no translation in this locale"), and a wrapping element carries the same text as a hover title, since a disabled button takes no pointer events. The labels stay directionally distinct so the two buttons don't collapse to one accessible name.

Scope. Ordering applies to reads of a sibling group — the taxonomy tree, the admin list, getTerms(), the sandbox bridges. It does not apply to the terms attached to one entry (post.terms.category): that list is flat and can span several groups, so a group-relative position says nothing about it. Those reads are unchanged and still order by label. Ordering terms within an entry is a separate feature, and the changeset says which of the two you get.

Rejected alternatives

Recorded so they don't have to be re-derived in review.

Per-row sort_order (a position per locale). The obvious alternative: make ordering presentational and per-locale, since collation is language-specific. It would delete most of this PR's machinery — the subset contract, the permute-within-slots logic, tie detection, renumbering, the stranded-term handling, and the "no locale parameter" caveat. Reorder would become what menus and widgets already are: send the full ordered list for (taxonomy, parent, locale) and write 0..n-1.

Not taken, for three reasons. Ongoing cost: every new term lands in an arbitrary position in every locale, so a multilingual site re-orders each taxonomy once per language, forever — the one-time cost of ordering each locale once is not the real bill. Consistency: parent_id is already per-group in this same PR, because tree shape is structural; splitting order from shape means a term can be the third child in English and the first in French while being the same node. And the internal-inconsistency argument against shared order — that the fallback tiebreak is label, which is per-locale — only bites for taxonomies nobody has ordered, where every position is 0; it is not a live contradiction in an ordered taxonomy.

The escape hatch for sites that genuinely need different orders per language is separate taxonomies, and the REST reference says so.

Renaming the route to /taxonomies/{name}/terms/reorder. Reads better and was drafted, then reverted. Term slugs have no reserved-word list — createTermBody validates slug as z.string().min(1) and nothing more — and terms are addressed as /terms/{slug}. A term with the slug reorder is creatable today, and nesting the endpoint would shadow its PUT and DELETE. byline-fields gets away with the same shape only because it reserves the slug at the data layer. Keeping reorder a level above /terms is what avoids the collision, which is also why menus is shaped this way. Nesting it would need a reserved-slug check on term creation first — a separate change.

Requiring the full sibling group in ids. Would make each reorder an absolute statement and remove the surprising-move consequence above. It also makes the feature unusable on a translated site: an editor in a locale that doesn't render every member could never name them all, so the group would be unorderable from that locale.

Locking nextSortOrder against concurrent creates. It reads the group's bounds outside the transaction, so two simultaneous creates can land on the same position. Left as is: ties are already a state the reorder path detects and renumbers away, they are visually harmless in the meantime (the label/id tiebreak still gives a stable order), and term creation is a rare, single-admin action. Worth knowing that ties are a live state and not only a migration artifact — which is why the tie path is tested directly rather than only through the migration.

Exposing sortOrder in TermData. It is on the repository's Taxonomy type and reaches the sandbox bridges via selectAll(), but REST clients can't read positions. Deliberate: the subset contract's whole point is that absolute positions are the server's business, and publishing them invites clients to depend on values the API doesn't promise. List order is the contract. Reversible later if a real client needs it — adding a field is additive.

Type of change

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

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Make taxonomies sortable #756

Please confirm Discussion #756 covers dropping the alphabetical fallback. It approved manual ordering; this PR also stops new terms from slotting in alphabetically, which is a visible behaviour change for existing sites. Happy to open a follow-up Discussion if that wants its own sign-off.

A second thing worth an explicit nod: the migration re-sorts non-source locales. Described at the top. It follows from the shared-position design rather than being a separate choice, but it is the one upgrade-visible regression in the PR, so it should be a decision rather than a surprise.

RTL: verified in a browser. The Taxonomies screen was driven in Arabic (ar, dir=rtl): sidebar and row actions mirror correctly, nested children indent from the right (marginInlineStart), the caret icons are vertically symmetric and need no flip, and end-of-group disabled states are correct for both the root group and a nested one. Moves were exercised in RTL and persisted. The stranded-term explanation was added after that browser pass and was not re-driven in ar; it introduces no directional classes (a flex gap-2 wrapper plus text), so it is layout-neutral by inspection. The caret labels are not yet translated in ar — expected, since catalogs are extracted on merge.

Performance

No new queries on the logged-out hot path. Every route's query count is unchanged: both scripts/query-counts.snapshot.{sqlite,d1}.json are byte-identical to main, and the only diff under scripts/ is the two ORDER BY lines in the recorded query text for each dialect. The folded content aggregate is byte-identical to main too, since entry-term hydration is out of scope.

The one query this PR adds is the bounds lookup that places a term, on two admin write paths: term create, and a term update that actually changes the parent. Reordering itself now costs 1 statement for a swap, and ceil(n/32) for a renumbering of an n-member group, down from one statement per changed group.

sort_order leads the ORDER BY and no index satisfies it, so the seeked sibling group is sorted in a temp b-tree. That's deliberate: the seek is the part that matters on stats-blind SQLite/D1, the sort is over one taxonomy's terms in one locale, and it costs no extra rows read — which is what D1 bills. ORDER BY label had the same temp b-tree before terms were sortable, so this isn't a regression the manual order introduced.

tests/integration/taxonomy-term-order-plan.test.ts pins this: both reads must still seek through idx_taxonomies_name_locale / idx_taxonomies_parent and must not fall back to scanning the whole locale (the #1723 regression).

Screenshots / test output

Full suites, all green:

packages/core       Test Files  414 passed | 1 skipped (415)
                         Tests  5245 passed | 3 skipped (5248)
packages/admin      Test Files  115 passed (115)
                         Tests  1374 passed (1374)
packages/cloudflare Test Files   20 passed (20)
                         Tests  294 passed (294)
packages/workerd    Test Files   11 passed (11)
                         Tests   76 passed (76)

pnpm typecheck and pnpm lint:json (0 diagnostics) both clean. Postgres parity was not run locally — CI covers it.

Tests:

  • tests/unit/taxonomies/term-reorder.test.ts — creation order, explicit order, order reflected in the public runtime helper, append on create, children ordered independently of roots, append on reparent, and the three REORDER_MISMATCH rejections (term outside the group, duplicate id, term from another taxonomy) each asserted to leave the stored order untouched. For the per-term position: a translation takes its source's place, an order set from one locale applies to the others, a translation_group is accepted in place of a row id, every row of a reparented term stays at one position, a partly visible group permutes within the slots it occupies, a term left out of the list keeps its place, and a child whose parent isn't translated is refused as a root. Migration 056 is covered by dropping and re-minting: rendered order is preserved, and one position is minted per translation group.
  • tests/integration/taxonomy-reorder-writes.test.ts — the batched write. One statement for a whole group; a renumbering past the chunk size producing ceil(n/32) statements rather than n; correct final order across a chunk boundary; every position still distinct afterwards; and no statement exceeding 100 bound parameters. Checked against the previous per-group loop — the two statement-count assertions fail on it, so they bite on a regression rather than restating the implementation.
  • tests/integration/taxonomy-term-order-plan.test.ts — the query-plan assertions above, against the repository's real emitted SQL.
  • TaxonomyManager.test.tsx — request bodies for a root group and a nested group, end-of-group buttons disabled, an untranslated-parent term's carets disabled (asserted through the explanatory accessible name) and left out of the group's ids, plus direct unit tests for the tree-splice and slot-permutation helpers.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Review response (pullrequestreview-4890046643)

All five findings addressed in 04b04b1. The invariant gap was real and is fixed, though not where the suggestion put it.

translation_group may be NULL — fixed, at the source

The finding is right about the mechanism: applyPositions and the reparent in repo.update both match translation_group = ?, while every caller derives a group as translationGroup ?? id. A row whose column is NULL matches nothing, and the reorder reports success having written nothing.

That is now a committed regression test — term-reorder.test.ts, "seeds a translation_group the term never got". Against the previous commit it fails exactly as described: handleTermReorder returns success: true and the listing is unchanged.

Where I diverged is the fix. The suggestion keys the runtime writes on COALESCE(translation_group, id); migration 056 now seeds the column instead, and its own COALESCE is gone, so the migration and the runtime agree on the plain column.

Reasons, in order of weight:

  • Coalescing at these two sites leaves the row broken everywhere else. translation_group is what content_taxonomies.taxonomy_id stores and joins on, what findTranslations selects by, and what delete uses to find orphaned pivots. A term with a NULL group cannot be attached to an entry, cannot be translated, and does not clean up after itself. Reorder is not where that row first goes wrong — it is just the site the review happened to land on. Patching the two write paths would leave a term that reorders correctly and is broken in five other ways.
  • WHERE COALESCE(translation_group, id) IN (...) is not sargable. It cannot use idx_taxonomies_translation_group, so a reorder full-scans taxonomies — once per chunk, on the write path, and rows scanned is what D1 bills. The same applies to the eb.or([...]) form suggested for the reparent.
  • The premise the finding rests on is narrower than it reads. "Legacy singleton terms may still have NULL" is not reachable through any supported path. Migration 036 backfills translation_group = id for every pre-existing row on both dialects — rebuildTaxonomies for SQLite, pgWiden for Postgres — with a committed test ("backfills translation_group = id for pre-existing rows"). The repository is the only insert path in the tree and always sets the column. So the seed normally matches zero rows.

That last point is why I did not treat this as "the migration knows something the runtime doesn't". It knew nothing; the COALESCE was defensive, its comment asserted a nullability the schema history doesn't support, and that comment is what made the two sites look like they disagreed on a live invariant. Since a NULL is nonetheless possible by direct SQL, and 056 is the migration that starts keying group writes on the column, repairing it there costs one idempotent statement and makes the invariant true rather than worked around.

repo.update reparent

Same root cause, fixed by the same seed — no change to the WHERE clause. existing.translationGroup ?? existing.id now always takes the first branch. The ?? stays because Taxonomy.translationGroup is typed nullable; tightening that type touches rowToTaxonomy and every consumer, which is its own change.

Comment discipline (three findings)

Taken. applyPositions' JSDoc, migration 056's file-level block, and the two TaxonomyManager comments were all narrating decisions rather than telling a reader something the code can't. Trimmed as suggested, with two lines kept rather than deleted outright:

  • The scope line keeps a one-line "why" (each body is an absolute order, so queued moves have to land in click order). Without it the property reads as decorative, and TanStack's scope is not widely known to serialize.
  • onSettled keeps one line noting that isMutating counts the calling mutation, so > 1 means another is queued. The off-by-one is the part a reader gets wrong; the rest of the narrative is gone.

Happy to drop both if you'd rather they were absent.

Verification

pnpm typecheck clean, pnpm lint:json 0 diagnostics, pnpm format run. packages/core 5246 passed | 3 skipped (415 files), admin TaxonomyManager.test.tsx 23 passed. Postgres parity via CI as before.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 04b04b1

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

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Minor
emdash Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash 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 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,161 lines across 24 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 Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 04b04b1

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@ascorbic

ascorbic commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Just needs the migrations renumbering and either accepting main's query snapshots so they're updated in CI (easiest), or running the update script yourself.

MA2153 and others added 4 commits August 8, 2026 17:29
Term listings were ordered by label alone, so the only way to control
how a taxonomy renders on a site was to rename its terms. Add a
`sort_order` column and order every term read by (sort_order, label, id).

Existing rows default to 0, which keeps a taxonomy nobody has reordered
alphabetical -- the behaviour before this change. A group only becomes
manually ordered once it is reordered, at which point it is renumbered
0..n-1 and new terms append to it; groups still at a uniform 0 keep
inserting new terms alphabetically.

Ordering is scoped to one sibling group in one locale so an order set in
one language cannot decide placement in another, and reordering never
reparents -- the endpoint rejects any list that is not the group's exact
membership rather than applying a stale one.

sort_order leads the ORDER BY and no index satisfies it, so the seeked
group is sorted in a temp b-tree. That costs no extra rows read (what D1
bills) and ORDER BY label had the same sort before; the alternative is a
five-column index paid on every term write. The query-plan test pins the
index seek so the emdash-cms#1723 full-locale scan cannot come back unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two places assumed a term's `parent_id` names the group it belongs to.
Neither holds once the tree is hierarchical.

The admin derived the group of a move from the moving term, so a term
whose parent has no row in the current locale -- which the term list
shows at the top level -- addressed its absent parent's group. That group
has no members, so every move on such a row failed with
REORDER_MISMATCH. The group now travels with the siblings it is rendered
from, which is what the server resolves membership against.

Reparenting left `sort_order` alone, so a term carried a position from
the group it left. Beyond landing mid-list, one non-uniform value makes a
group nobody has ordered read as ordered, switching it from alphabetical
insertion to append-at-end. An update that changes `parent_id` now
re-places the term by the same rule a new term uses.

`nextSortOrder` still scopes a group by the raw `parent_id` column while
the reorder endpoint scopes it by the effective parent; they agree except
under an untranslated parent, where a new top-level term can land
mid-list. Closing that costs a parent-existence check plus a correlated
NOT EXISTS on every term create -- documented on the PR rather than paid
for here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A translation copied its source's `sort_order` unconditionally, so one
created under a different parent than its source -- including with no
parent at all, which makes it a root in the target locale -- carried in a
position belonging to a group it was not joining. It lands mid-list, and
one non-uniform value makes a group nobody has ordered read as ordered,
switching it from alphabetical insertion to append-at-end.

The copy now applies only when the new row joins the group that mirrors
the source's; otherwise the row is placed by the same rule a new or
reparented term uses. A translation of a term whose parent is unchanged
still inherits its source's position, so a translated tree keeps the
order its source was given.

Reachable from `POST /taxonomies/{name}/terms` with `translationOf` and
from a seed whose translated term declares a different parent. The
dedicated translations route passes the source's parent through, so the
admin's Translate button never hit this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The manual-order work left comments that argue for the code rather than
explain it: a query-plan test header that justified its own assertions and
told a reviewer which ones to delete, issue references that go stale on
merge, and "rather than X" notes restating decisions the code already makes
plain.

Also drops a filler assertion that only existed to consume a destructured
binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MA2153
MA2153 force-pushed the feat/sortable-taxonomy-terms branch from 5436c84 to 81306e6 Compare August 8, 2026 14:34
@MA2153 MA2153 added the bot:review Trigger an emdashbot code review on this PR label Aug 8, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 8, 2026
@MA2153 MA2153 added the bot:review Trigger an emdashbot code review on this PR label Aug 8, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 8, 2026
Reworks manual term ordering so a position belongs to a term rather than
to one of its rows, and drops the alphabetical fallback entirely.

`sort_order` is now per translation_group: every row of a group carries
the same value, mirroring `parent_id`, which already stores the parent's
translation_group rather than a row id. Sibling groups key on the raw
`parent_id` column everywhere -- placement, reordering, and the
migration -- so one definition decides what a group is.

That removes the cases the previous model had to reason about. There is
no rule for placing a translation, because a translation is the same
term at the term's position. A half-translated locale can't produce
gaps or duplicate positions. And the reorder endpoint no longer takes a
`locale`, because a position doesn't have one -- which also removes the
cross-locale renumbering that omitting it used to cause.

Migration 056 mints a position for every existing term instead of
defaulting them all to 0. Numbering by (label, id) keeps rendered order
identical the moment it runs; what changes is that a term created
afterwards appends to the end of its group rather than slotting in
alphabetically. Inferring "has this group been ordered?" from whether
its values were uniform is gone with it.

Reorder now permutes the listed terms within the positions they already
occupy instead of demanding the group's exact membership. A locale
renders only the terms translated into it, so a caller often cannot name
every member; a member left out keeps its place, which also makes a
stale list harmless rather than rejected.

A child whose parent has no row in the current locale is rendered at the
top level but belongs to its parent's group. Its carets are disabled and
it is left out of the ids sent for the group it is drawn in, rather than
silently addressing a group it isn't in.

Ordering applies to reads of a sibling group. The terms attached to one
entry are a flat list that can span groups, so those reads are unchanged
and still order by label; ordering terms within an entry is a separate
feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
emdashbot Bot and others added 3 commits August 8, 2026 17:15
A term's `parent_id` holds the parent's translation_group, so it is
locale-agnostic and every row of a group should carry the same value.
`update()` wrote it with `WHERE id = ?`, so reparenting a term left it
nested in the locale it was moved in and a root in all the others.
Migration 045 rewrote parent_id values but scoped its UPDATE to
`parent_id IS NOT NULL`, so it converged groups whose rows all named a
parent and never the ones where a row still held null -- the exact shape
a one-row reparent produces. Reparenting now writes the group.

Sibling positions are keyed the same way, so a divergent group also put
two roots on the same position, and `reorder()` cannot permute positions
that tie: it returned success while the list never moved, leaving the
group unorderable. It now renumbers 0..n-1 when the positions it is given
tie, which honours the request and clears the tie. Renumbering keys on
each member's index in the rendered order rather than on a row id, since
ULIDs minted in the same millisecond have no order and would make the
result arbitrary. `reorder` takes the sibling list in rendered order for
that reason.

Creating a translation into a different parent inherited the source's
position, colliding with whatever already held it. A translation now only
takes the group's position while it stays in that group, and appends
otherwise.

Migration 056 guarded its whole body on the column it adds, so a run that
died during the backfill was never recorded, retried, and immediately
early-returned -- stranding most groups at 0 with no way to re-run.
Only the ALTER is guarded now and both passes run unconditionally,
following 051. The backfill was one UPDATE per translation group, which
spends a D1 subrequest per term and exhausts the 1,000 a Worker gets on
the free plan on any sizeable taxonomy; it is now chunked CASE statements
at 32 groups, three bound parameters each, inside D1's 100-parameter
ceiling. The parent_id repair runs before numbering, because numbering
keys on parent_id and would otherwise place a divergent group by whichever
row it happened to read.

The reorder endpoint's `order` response was never read: the admin client
returned it and the caller discarded it, and its localeCompare tiebreak
could disagree with the next list read. It now answers `{ reordered: true }`
and `repo.reorder` returns void. `ids` caps at 100 to match the comparable
arrays in the same schema, which also bounds the writes in one transaction.

Tests: the runtime-helper test could not fail, because `cachedQuery` runs
its loader when no cache backend is configured, so invalidation is
invisible in that file. It is renamed to say it covers ordering, and
term-list-object-cache.test.ts covers invalidation against a real backend.
Adds coverage for optimistic rendering, for the queued-move refetch guard,
and for the bridge ORDER BY on both sandbox implementations -- the workerd
fixture's rows all sat at the default position, so its order assertion was
purely alphabetical. Drops the half of the findByName test that asserted
`repo.reorder`'s calling convention rather than any behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MA2153 and others added 3 commits August 9, 2026 01:06
A reorder applied one UPDATE per translation group inside withTransaction,
which degrades to a bare passthrough on D1. A failure partway left the
sibling group half-permuted, and the tie-repair path spent a subrequest
per member of the group.

Batch the writes into one CASE statement per 32 groups, the same
parameter arithmetic migration 056 uses. A swap is now a single
statement and cannot tear; only a renumbering wide enough to span chunks
can, and that leaves ties the next reorder renumbers away.

The migration keeps its own copy of the helper: a migration has to stay
frozen, and sharing one would let a later edit rewrite history for
anyone mid-upgrade.

Also:

- Say that the migration preserves the *source* locale's order and
  re-sorts the others. One position per translation group means one
  collation wins; the docstring and changeset claimed more than that.
- Stop claiming term listings arrive "in rendered order" — with locales
  interleaved a tie falls to whichever locale's label sorts first.
- Explain the disabled carets on a term whose parent has no translation
  in the current locale, in the accessible name and as a hover title.
- Narrow the changeset: getTerms() honours the manual order, the terms
  attached to an entry are still alphabetical.
- Make reorderTermsBody strict, and document that omitted terms hold
  absolute positions, so a one-step move in a partial list can be a
  two-step move in a fuller one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Postgres resolves a CASE whose THEN arms are all untyped bind parameters
to text, then refuses to assign that to an integer column:

  column "sort_order" is of type integer but expression is of type text

The WHEN parameters take text from the comparison against the selector,
and nothing pins the THEN parameters, so the whole expression lands on
text. node-postgres sends numbers with an unspecified type OID, so a JS
number does not settle it either. SQLite does not type-check the
assignment, which is why this only showed on the Postgres dialect runs.

Both the batched reorder write and migration 056's backfill emit that
shape, so on Postgres every term reorder failed -- swallowed into a
failed ApiResult rather than surfacing -- and the migration could not
mint positions at all.

CAST the value in each arm. applyByGroup serves parent_id as well as
sort_order, so it takes the cast target from its column argument. The
cast binds no parameters, so the three-per-group budget behind
GROUPS_PER_UPDATE is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 is the right feature for the right problem, and the chosen design — a single sort_order per translation group, shared across locales — is argued through cleanly in both the code and the changeset. It fits EmDash’s existing model where parent_id already stores the parent’s translation group. The implementation is careful about the logged-out hot path (query counts unchanged), CSRF/auth gating, object-cache invalidation, D1 parameter limits, and multilingual subtleties like partial-locale reordering and orphaned children.

What I checked:

  • TaxonomyRepository reads/writes, handleTermReorder, the new route, and the Admin UI optimistic updates.
  • Migration 056 backfill and parent repair logic.
  • Cloudflare and workerd sandbox bridges (both order by sort_order).
  • Query-count metadata (only the recorded SQL text changed; counts are unchanged).
  • Lingui wrapping and RTL-safe Tailwind in the Admin UI.
  • Tests added for reorder semantics, batched writes, query plans, and cache invalidation.

Headline conclusion: the change is solid, but there are a few issues to address before merge. The most concrete one is an invariant gap between the migration and the runtime writes: the migration keys grouping on COALESCE(translation_group, id) because translation_group is nullable, while two new write paths only match translation_group = ?. That can silently drop reorders/reparents for legacy singleton terms. The remaining findings are AGENTS.md comment-discipline violations — several new comments explain why the code exists or reproduce PR-level narrative rather than telling future readers something the code cannot.

Comment thread packages/core/src/database/repositories/taxonomy.ts
Comment thread packages/core/src/database/repositories/taxonomy.ts
Comment thread packages/admin/src/components/TaxonomyManager.tsx Outdated
Comment thread packages/admin/src/components/TaxonomyManager.tsx Outdated
Comment thread packages/core/src/database/repositories/taxonomy.ts
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 8, 2026
Reorder and reparent both key sibling groups on `translation_group`, while
callers derive a group as `translationGroup ?? id`. A row whose column is
null therefore matches nothing: the write reports success and changes
nothing.

Migration 036 seeded the column on every row it rebuilt and the repository
has set it on every insert since, so no supported path produces a null. 056
normalises anyway, since it is the migration that starts keying group writes
on the column in SQL. Repairing the row is strictly better than coalescing
at each read site: a null is equally invisible to translation lookups and to
the `content_taxonomies` join, and an expression key would cost the index
seek on every reorder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Aug 9, 2026
@MA2153 MA2153 added the bot:review Trigger an emdashbot code review on this PR label Aug 9, 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 is the right feature for the right problem, and the shared-position-per-translation-group design is argued through cleanly in the code, changeset, and REST docs. The prior review's main concern — an invariant gap where runtime writes keyed on translation_group while the migration used COALESCE(translation_group, id) — is fixed by migration 056 now seeding translation_group = id for any legacy row before the rest of the migration runs, plus a regression test that fails on the old code.

What I checked:

  • TaxonomyRepository reads/writes, handleTermReorder, the new route, and the Admin UI optimistic updates.
  • Migration 056 backfill, parent-repair logic, and the null-group seed.
  • Cloudflare and workerd sandbox bridges (both order by sort_order).
  • Query-count metadata and query-plan assertions.
  • Lingui wrapping and RTL-safe Tailwind in the Admin UI.
  • Tests added for reorder semantics, batched writes, query plans, cache invalidation, and the null-group regression.

No remaining findings. The implementation is careful about the logged-out hot path (query counts unchanged), CSRF/auth gating, object-cache invalidation, D1 parameter limits, and multilingual subtleties like partial-locale reordering and stranded children. LGTM.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 9, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Aug 9, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

@ascorbic
ascorbic merged commit ea4c39b into emdash-cms:main Aug 9, 2026
51 checks passed
@emdashbot emdashbot Bot mentioned this pull request Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants