Fixed lazy URL primary_tag on tied tag sort_order - #29482
Conversation
ref https://linear.app/ghost/issue/HKG-1822 primary_tag is a post's first tag if it is public, so it depends on the order the tags relation returns. The lazy URL path loads tags through the model's tags() relation, which orders by sort_order with no tie-breaker. When several of a post's tags share a sort_order — the state bulk and import operations leave behind — MySQL is free to return them in different orders per query, so the lazy path could resolve a different primary_tag than the eager service and build a different URL for the same post under a {primary_tag} permalink (seen in production as eager serving /disaster-preparedness/ while the serializer produced /claremont-elmwood/). Order tags()/authors() by posts_tags.id / posts_authors.id (attach order) after sort_order. That is the tag the eager service already serves, so the lazy path now matches it without touching eager's own raw-knex query. The pivot id is added to withPivot because the relation query uses DISTINCT, which requires ORDER BY columns to appear in the SELECT list.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThe Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run ghost:test:ci:integration |
✅ Succeeded | 2m 12s | View ↗ |
nx run ghost:test:integration |
✅ Succeeded | 3m 1s | View ↗ |
nx run ghost:test:legacy |
✅ Succeeded | 3m 3s | View ↗ |
nx run ghost:test:e2e |
✅ Succeeded | 2m 41s | View ↗ |
nx run-many -t test:unit -p ghost |
✅ Succeeded | 25s | View ↗ |
nx run ghost-monorepo:lint:boundaries |
✅ Succeeded | 20s | View ↗ |
nx run-many -t lint -p ghost,ghost-monorepo |
✅ Succeeded | 19s | View ↗ |
nx run-many --target=build --projects=tag:publi... |
✅ Succeeded | 1s | View ↗ |
nx run @tryghost/admin:build |
✅ Succeeded | 4s | View ↗ |
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗
☁️ Nx Cloud last updated this comment at 2026-07-21 05:14:59 UTC
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #29482 +/- ##
==========================================
+ Coverage 74.48% 74.50% +0.01%
==========================================
Files 1602 1602
Lines 140563 140577 +14
Branches 17062 17059 -3
==========================================
+ Hits 104699 104735 +36
+ Misses 34845 34789 -56
- Partials 1019 1053 +34
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|

Why
Under a
{primary_tag}or{primary_author}permalink, a post's URL is built from its primary tag — the post's first tag, if that tag is public. So the URL depends on the order the tags relation returns.The lazy URL path and the eager service load a post's tags through different queries, and both order by
sort_orderwith no tie-breaker. When several of a post's tags share asort_order— the state bulk edits and imports leave behind — MySQL is free to return them in a different order for each query. So the lazy path and eager pick a different primary tag and build a different URL for the same post. In production this surfaced as eager serving/disaster-preparedness/…while the lazy comparison produced/claremont-elmwood/…for the same post.Eager is authoritative and is being removed, so rather than touch its query this pins only the lazy path. The model's
tags()andauthors()relations now order by the pivot id (posts_tags.id/posts_authors.id) aftersort_order. That's attach order, which is the tag eager already serves, so the lazy path now matches it without eager changing.The pivot id is pulled into the relation's
withPivotbecause the query usesDISTINCT, which requires ORDER BY columns to be in the SELECT list.The divergence only happens on MySQL — sqlite resolves the tie consistently — so the added regression test pins the deterministic ordering rather than reproducing the divergence.