feat(taxonomies): let terms carry a manual order - #2353
Conversation
🦋 Changeset detectedLatest commit: 04b04b1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
Scope checkThis 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
|
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. |
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>
5436c84 to
81306e6
Compare
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>
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>
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>
There was a problem hiding this comment.
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:
TaxonomyRepositoryreads/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.
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>
There was a problem hiding this comment.
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:
TaxonomyRepositoryreads/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.
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_orderaddstaxonomies.sort_orderand 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_orderis pertranslation_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 mirrorsparent_id, which already stores the parent'stranslation_grouprather 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-1permutation.Sibling groups are keyed on the raw
parent_idcolumn throughout — placement, reordering, and the migration all use the same definition.parent_idis locale-agnostic, which is what lets one position mean the same thing everywhere.API.
POST /_emdash/api/taxonomies/{name}/reorderwith{ parentId?, ids }, gated on the existingtaxonomies:managepermission 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 nolocaleparameter — a position has no locale.idsare row ids or translation groups and resolve to the latter.idsmay 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 isREORDER_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 showB. 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.
reorderapplies positions with oneCASEstatement per 32 translation groups rather than oneUPDATEper group. Two reasons, both D1:withTransactionruns its callback bare there (documented intransaction.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.32 groups per statement is the same arithmetic the migration uses: three bound parameters each (a
CASE WHEN/THENpair plus oneINslot), 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
scopeinstead 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 write0..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_idis 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 islabel, 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 —createTermBodyvalidatesslugasz.string().min(1)and nothing more — and terms are addressed as/terms/{slug}. A term with the slugreorderis creatable today, and nesting the endpoint would shadow itsPUTandDELETE.byline-fieldsgets away with the same shape only because it reserves the slug at the data layer. Keepingreordera level above/termsis what avoids the collision, which is also whymenusis 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
nextSortOrderagainst 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 (thelabel/idtiebreak 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
sortOrderinTermData. It is on the repository'sTaxonomytype and reaches the sandbox bridges viaselectAll(), 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
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.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 inar; it introduces no directional classes (aflex gap-2wrapper plus text), so it is layout-neutral by inspection. The caret labels are not yet translated inar— 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}.jsonare byte-identical tomain, and the only diff underscripts/is the twoORDER BYlines in the recorded query text for each dialect. The folded content aggregate is byte-identical tomaintoo, 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 ann-member group, down from one statement per changed group.sort_orderleads theORDER BYand 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 labelhad 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.tspins this: both reads must still seek throughidx_taxonomies_name_locale/idx_taxonomies_parentand must not fall back to scanning the whole locale (the #1723 regression).Screenshots / test output
Full suites, all green:
pnpm typecheckandpnpm 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 threeREORDER_MISMATCHrejections (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 producingceil(n/32)statements rather thann; 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
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_groupmay beNULL— fixed, at the sourceThe finding is right about the mechanism:
applyPositionsand the reparent inrepo.updateboth matchtranslation_group = ?, while every caller derives a group astranslationGroup ?? id. A row whose column isNULLmatches 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:handleTermReorderreturnssuccess: trueand 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 ownCOALESCEis gone, so the migration and the runtime agree on the plain column.Reasons, in order of weight:
translation_groupis whatcontent_taxonomies.taxonomy_idstores and joins on, whatfindTranslationsselects by, and whatdeleteuses to find orphaned pivots. A term with aNULLgroup 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 useidx_taxonomies_translation_group, so a reorder full-scanstaxonomies— once per chunk, on the write path, and rows scanned is what D1 bills. The same applies to theeb.or([...])form suggested for the reparent.NULL" is not reachable through any supported path. Migration 036 backfillstranslation_group = idfor every pre-existing row on both dialects —rebuildTaxonomiesfor SQLite,pgWidenfor 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
COALESCEwas 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 aNULLis 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.updatereparentSame root cause, fixed by the same seed — no change to the
WHEREclause.existing.translationGroup ?? existing.idnow always takes the first branch. The??stays becauseTaxonomy.translationGroupis typed nullable; tightening that type touchesrowToTaxonomyand every consumer, which is its own change.Comment discipline (three findings)
Taken.
applyPositions' JSDoc, migration 056's file-level block, and the twoTaxonomyManagercomments 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:scopeline 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'sscopeis not widely known to serialize.onSettledkeeps one line noting thatisMutatingcounts the calling mutation, so> 1means 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 typecheckclean,pnpm lint:json0 diagnostics,pnpm formatrun.packages/core5246 passed | 3 skipped (415 files), adminTaxonomyManager.test.tsx23 passed. Postgres parity via CI as before.