Skip to main content

max / makenotwork

Make tag following hierarchical Following a tag now matches its whole subtree, the way the tag filter beside it already did. The two disagreed while sitting next to each other in the drill-down: the filter expanded via path LIKE, following joined follows.target_id = item_tags.tag_id exactly. Since item_tags holds only directly-assigned tags and the write path enforces depth >= 3, following any depth-1 or depth-2 tag matched nothing, forever. The three feed CTEs were byte-identical, so they are now one shared FOLLOWED_ITEM_IDS_CTE constant, expanded once. The descendant match is SQL-side (t.path LIKE ft.path || '.%') rather than a tagtree::like_descendant_pattern bind, because the followed paths come out of follows in the same query and a host-side build would cost a round trip on a per-render path. That is safe only because tagtree validation restricts a path to lowercase alphanumerics, hyphens and dots; the constant documents the invariant and its expiry condition. The follow control is offered on every rung, and build_sidebar's follow-state query is no longer narrowed to assignable rows. It stays scoped to the rendered rows, so the C5-1 over-fetch fix still holds. Measured over 50k items and the 119-tag taxonomy: a like-for-like leaf follow got faster (4.6ms -> 2.6ms; the join to tags improves planner selectivity), and both tags scans are 3-buffer reads. A branch follow costs 21ms for 5,676 items and a root follow 41ms for 15,479, because the CTE materializes the full id set before the outer LIMIT applies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 18:23 UTC
Commit: da4de4c73a1f29ebe78303c4dc0bbb68f99d04f5
Parent: 80098a6
6 files changed, +170 insertions, -98 deletions
@@ -20,7 +20,12 @@ You can follow three kinds of things:
20 20 |-------------|--------------------------|
21 21 | **Creator** | Everything they publish, across all their projects |
22 22 | **Project** | New items added to that specific project |
23 - | **Tag** | New items from any creator tagged with that tag |
23 + | **Tag** | New items from any creator tagged with that tag, or anything under it |
24 +
25 + Following a tag follows its whole branch. Follow `audio` and you get everything
26 + tagged anywhere beneath it, down to the most specific tag. Follow
27 + `audio.genre.techno` and you get only that. Pick the rung that matches how wide
28 + you want the feed to be.
24 29
25 30 Unfollow the same way: click the Follow button again.
26 31
@@ -74,15 +74,27 @@ pub async fn is_following(
74 74 Ok(row.0)
75 75 }
76 76
77 - /// Get recent public items from all users and projects this user follows.
78 - /// Returns up to 50 items, newest first.
79 - #[tracing::instrument(skip_all)]
80 - pub async fn get_followed_items(
81 - pool: &PgPool,
82 - follower_id: UserId,
83 - ) -> Result<Vec<super::models::DbItem>> {
84 - let items = sqlx::query_as::<_, super::models::DbItem>(
85 - r#"
77 + /// The set of item ids visible in a follower's feed: everything from a followed
78 + /// user, a followed project, or a followed tag. `$1` is the follower.
79 + ///
80 + /// Tag following is HIERARCHICAL — following a branch follows everything under
81 + /// it, matching how the discover tag filter has always behaved
82 + /// (`db::discover::TAG_CLAUSE`). The two used to disagree while sitting next to
83 + /// each other in the drill-down: the filter expanded via `path LIKE`, following
84 + /// joined `follows.target_id = item_tags.tag_id` exactly. Since `item_tags`
85 + /// holds only directly-assigned tags and the write path enforces `depth >= 3`,
86 + /// following any depth-1 or depth-2 tag matched nothing, forever.
87 + ///
88 + /// The descendant match is `t.path LIKE ft.path || '.%'` rather than a
89 + /// `tagtree::like_descendant_pattern` bind, because the followed paths are not
90 + /// known host-side here — they come out of `follows` in the same query. That is
91 + /// safe only because `tagtree` validation restricts a path to lowercase
92 + /// alphanumerics, hyphens, and dots, so `escape_like` is a no-op on every value
93 + /// that can reach this column. If that charset ever widens, this must become a
94 + /// bound pattern built by `tagtree::like_descendant_pattern`.
95 + ///
96 + /// Shared verbatim by the three feed queries below so the three cannot drift.
97 + const FOLLOWED_ITEM_IDS_CTE: &str = r#"
86 98 WITH followed_item_ids AS (
87 99 SELECT i.id FROM items i
88 100 JOIN projects p ON i.project_id = p.id
@@ -100,15 +112,29 @@ pub async fn get_followed_items(
100 112 JOIN projects p ON i.project_id = p.id
101 113 JOIN users u ON u.id = p.user_id
102 114 JOIN item_tags it ON it.item_id = i.id
103 - JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
104 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
115 + JOIN tags t ON t.id = it.tag_id
116 + JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag'
117 + JOIN tags ft ON ft.id = f.target_id
118 + WHERE (t.id = ft.id OR t.path LIKE ft.path || '.%')
119 + AND i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
105 120 )
121 + "#;
122 +
123 + /// Get recent public items from all users and projects this user follows.
124 + /// Returns up to 50 items, newest first.
125 + #[tracing::instrument(skip_all)]
126 + pub async fn get_followed_items(
127 + pool: &PgPool,
128 + follower_id: UserId,
129 + ) -> Result<Vec<super::models::DbItem>> {
130 + let items = sqlx::query_as::<_, super::models::DbItem>(&format!(
131 + r#"{FOLLOWED_ITEM_IDS_CTE}
106 132 SELECT i.* FROM items i
107 133 JOIN followed_item_ids fi ON fi.id = i.id
108 134 ORDER BY i.created_at DESC
109 135 LIMIT 50
110 - "#,
111 - )
136 + "#
137 + ))
112 138 .bind(follower_id)
113 139 .fetch_all(pool)
114 140 .await?;
@@ -125,28 +151,8 @@ pub async fn get_followed_feed_items(
125 151 limit: i64,
126 152 offset: i64,
127 153 ) -> Result<Vec<super::models::DbDiscoverItemRow>> {
128 - let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>(
129 - r#"
130 - WITH followed_item_ids AS (
131 - SELECT i.id FROM items i
132 - JOIN projects p ON i.project_id = p.id
133 - JOIN users u ON u.id = p.user_id
134 - JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
135 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
136 - UNION
137 - SELECT i.id FROM items i
138 - JOIN projects p ON i.project_id = p.id
139 - JOIN users u ON u.id = p.user_id
140 - JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
141 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
142 - UNION
143 - SELECT i.id FROM items i
144 - JOIN projects p ON i.project_id = p.id
145 - JOIN users u ON u.id = p.user_id
146 - JOIN item_tags it ON it.item_id = i.id
147 - JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
148 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
149 - )
154 + let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>(&format!(
155 + r#"{FOLLOWED_ITEM_IDS_CTE}
150 156 SELECT
151 157 i.id,
152 158 i.title,
@@ -170,8 +176,8 @@ pub async fn get_followed_feed_items(
170 176 LEFT JOIN tags pt ON pt.id = pit.tag_id
171 177 ORDER BY i.created_at DESC
172 178 LIMIT $2 OFFSET $3
173 - "#,
174 - )
179 + "#
180 + ))
175 181 .bind(follower_id)
176 182 .bind(limit)
177 183 .bind(offset)
@@ -187,31 +193,11 @@ pub async fn count_followed_feed_items(
187 193 pool: &PgPool,
188 194 follower_id: UserId,
189 195 ) -> Result<i64> {
190 - let count: i64 = sqlx::query_scalar(
191 - r#"
192 - WITH followed_item_ids AS (
193 - SELECT i.id FROM items i
194 - JOIN projects p ON i.project_id = p.id
195 - JOIN users u ON u.id = p.user_id
196 - JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
197 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
198 - UNION
199 - SELECT i.id FROM items i
200 - JOIN projects p ON i.project_id = p.id
201 - JOIN users u ON u.id = p.user_id
202 - JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
203 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
204 - UNION
205 - SELECT i.id FROM items i
206 - JOIN projects p ON i.project_id = p.id
207 - JOIN users u ON u.id = p.user_id
208 - JOIN item_tags it ON it.item_id = i.id
209 - JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
210 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
211 - )
196 + let count: i64 = sqlx::query_scalar(&format!(
197 + r#"{FOLLOWED_ITEM_IDS_CTE}
212 198 SELECT COUNT(*) FROM followed_item_ids
213 - "#,
214 - )
199 + "#
200 + ))
215 201 .bind(follower_id)
216 202 .fetch_one(pool)
217 203 .await?;
@@ -113,16 +113,14 @@ async fn build_sidebar(
113 113 let drill_rows =
114 114 db::tags::tag_children_with_counts(db, browse_cursor, &facet_filters).await?;
115 115
116 - // Follow state, for the rungs that can carry a follow control. Scoped to
117 - // the assignable rows rather than the viewer's whole followed set (fuzz
118 - // 2026-07-06 C5-1), and skipped entirely for anonymous viewers.
116 + // Follow state for every rung on screen. Still scoped to the rendered
117 + // rows rather than the viewer's whole followed set (fuzz 2026-07-06
118 + // C5-1), and skipped entirely for anonymous viewers — but no longer
119 + // narrowed to assignable rows, since following is hierarchical and a
120 + // branch is now a legitimate follow target.
119 121 let followed: std::collections::HashSet<db::TagId> = match viewer_id {
120 122 Some(uid) => {
121 - let ids: Vec<_> = drill_rows
122 - .iter()
123 - .filter(|r| r.assignable)
124 - .map(|r| r.tag_id)
125 - .collect();
123 + let ids: Vec<_> = drill_rows.iter().map(|r| r.tag_id).collect();
126 124 db::follows::following_subset(db, uid, &ids).await?
127 125 }
128 126 None => std::collections::HashSet::new(),
@@ -136,11 +136,24 @@
136 136 <span>{{ row.label }}</span>
137 137 <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>
138 138 </label>
139 + {% endif %}
140 + {% if row.has_children %}
141 + <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}"
142 + aria-label="Browse inside {{ row.label }}">
143 + {% if !row.assignable %}<span>{{ row.label }}</span>
144 + <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %}
145 + <span class="tag-drill-chevron" aria-hidden="true">&rsaquo;</span>
146 + </a>
147 + {% endif %}
139 148 {# Follow is a SIBLING of the label, never inside it:
140 149 a control nested in a label would toggle the
141 - checkbox when clicked. Offered only on assignable
142 - leaves, because following is exact-match and a
143 - follow on a category would match nothing. #}
150 + checkbox when clicked. Offered on EVERY rung, not
151 + just assignable leaves: following is hierarchical,
152 + so a follow on a branch matches its whole subtree
153 + (see db::follows::FOLLOWED_ITEM_IDS_CTE). It is the
154 + last child so it right-aligns identically whether
155 + the row leads with a checkbox label or a drill-in
156 + link, both of which take flex: 1. #}
144 157 {% if sidebar.viewer_authenticated %}
145 158 {% if row.following %}
146 159 <button class="tag-follow-btn is-selected"
@@ -156,15 +169,6 @@
156 169 data-action="noop" data-stop>Follow</button>
157 170 {% endif %}
158 171 {% endif %}
159 - {% endif %}
160 - {% if row.has_children %}
161 - <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}"
162 - aria-label="Browse inside {{ row.label }}">
163 - {% if !row.assignable %}<span>{{ row.label }}</span>
164 - <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %}
165 - <span class="tag-drill-chevron" aria-hidden="true">&rsaquo;</span>
166 - </a>
167 - {% endif %}
168 172 </li>
169 173 {% endfor %}
170 174 </ul>
@@ -528,36 +528,38 @@ async fn price_filter_round_trips_into_its_inputs() {
528 528 // Tag following.
529 529 // ---------------------------------------------------------------------------
530 530
531 - /// The drill-down offers a follow control, and only where following works.
531 + /// The drill-down offers a follow control on every rung.
532 532 ///
533 - /// Following is exact-match: `follows.target_id` joins `item_tags.tag_id`
534 - /// directly, with no descendant expansion. Items carry depth-3+ leaves only, so
535 - /// a follow on a category would match nothing forever. The control is therefore
536 - /// offered on assignable rungs and withheld on navigational ones, even though
537 - /// the tag filter beside it does expand to descendants.
533 + /// Inverted 2026-07-20. Following used to be exact-match — `follows.target_id`
534 + /// joined `item_tags.tag_id` directly, with no descendant expansion — and since
535 + /// items carry depth-3+ leaves only, a follow on a category matched nothing
536 + /// forever. The control was therefore withheld on navigational rungs, even
537 + /// though the tag filter beside it did expand to descendants. Following is now
538 + /// hierarchical too, so the two agree and every rung is followable.
538 539 #[tokio::test]
539 - async fn follow_control_appears_only_on_assignable_rungs() {
540 + async fn follow_control_appears_on_every_rung() {
540 541 let mut h = TestHarness::new().await;
541 542 h.signup("followui", "followui@example.com", "password123").await;
542 543
543 - // Root: every rung is a depth-1 type, none assignable, so no follow control.
544 + // Root: depth-1 categories, navigational rather than assignable. Followable
545 + // now that a follow on a branch covers its subtree.
544 546 let root = h.client.get("/discover?mode=items").await;
545 547 assert!(root.status.is_success(), "{}", root.status);
546 548 assert!(
547 - !root.text.contains("tag-follow-btn"),
548 - "categories cannot be followed meaningfully, so offer no control"
549 + root.text.contains("tag-follow-btn"),
550 + "a category follow now covers its whole subtree, so offer the control"
551 + );
552 + assert!(
553 + root.text.contains("/api/follow/tag/"),
554 + "the control must address the tag follow endpoint"
549 555 );
550 556
551 - // Drilled to leaves: the control appears.
557 + // Drilled to leaves: the control is still there.
552 558 let leaves = h.client.get("/discover?mode=items&browse=audio.genre").await;
553 559 assert!(leaves.status.is_success(), "{}", leaves.status);
554 560 assert!(
555 561 leaves.text.contains("tag-follow-btn"),
556 - "assignable leaves should offer a follow control"
557 - );
558 - assert!(
559 - leaves.text.contains("/api/follow/tag/"),
560 - "the control must address the tag follow endpoint"
562 + "assignable leaves should still offer a follow control"
561 563 );
562 564 }
563 565
@@ -340,6 +340,83 @@ async fn following_a_tag_changes_the_feed() {
340 340 );
341 341 }
342 342
343 + /// Resolve a tag id by slug. Ancestors are created by `item_tagged_with` on the
344 + /// way down to the leaf, but only the leaf id is returned.
345 + async fn tag_id_for(h: &TestHarness, slug: &str) -> uuid::Uuid {
346 + sqlx::query_scalar("SELECT id FROM tags WHERE slug = $1")
347 + .bind(slug)
348 + .fetch_one(&h.db)
349 + .await
350 + .expect("tag by slug")
351 + }
352 +
353 + /// Following a BRANCH surfaces items tagged with a descendant leaf.
354 + ///
355 + /// This is the whole point of making following hierarchical, and nothing else
356 + /// catches a broken subtree expansion: `item_tags` holds only directly-assigned
357 + /// tags, and the write path enforces depth >= 3, so before this change a follow
358 + /// on a depth-1 or depth-2 tag matched nothing forever.
359 + #[tokio::test]
360 + async fn following_a_parent_tag_surfaces_descendant_items() {
361 + let mut h = TestHarness::new().await;
362 + let (_item, leaf) =
363 + item_tagged_with(&mut h, "branchcreator", "Deep Release", "audio.genre.techno").await;
364 + let branch = tag_id_for(&h, "audio.genre").await;
365 + assert_ne!(branch, leaf, "the followed branch must not be the leaf itself");
366 + h.client.post_form("/logout", "").await;
367 +
368 + h.signup("branchfollower", "branchfollower@test.com", "password123").await;
369 +
370 + let before = h.client.get("/feed").await;
371 + assert!(
372 + !before.text.contains("Deep Release"),
373 + "an unfollowed branch must not put items in the feed"
374 + );
375 +
376 + let follow = h
377 + .client
378 + .post_form(&format!("/api/follow/tag/{branch}"), "")
379 + .await;
380 + assert!(follow.status.is_success(), "follow failed: {}", follow.status);
381 +
382 + let after = h.client.get("/feed").await;
383 + assert!(after.status.is_success(), "{}", after.status);
384 + assert!(
385 + after.text.contains("Deep Release"),
386 + "following audio.genre must surface an item tagged audio.genre.techno; body was: {}",
387 + &after.text[..after.text.len().min(600)]
388 + );
389 + }
390 +
391 + /// The same expansion has to hold in `get_followed_items`, which backs the
392 + /// signed RSS feed and is a separate query from the feed page's.
393 + #[tokio::test]
394 + async fn following_a_parent_tag_surfaces_descendant_items_in_rss() {
395 + let mut h = TestHarness::new().await;
396 + let (_item, _leaf) =
397 + item_tagged_with(&mut h, "branchrss", "Deep Syndicated", "audio.genre.dub").await;
398 + let branch = tag_id_for(&h, "audio").await;
399 + h.client.post_form("/logout", "").await;
400 +
401 + let follower = h.signup("branchrssfollower", "branchrssf@test.com", "password123").await;
402 + h.client
403 + .post_form(&format!("/api/follow/tag/{branch}"), "")
404 + .await;
405 +
406 + let url = makenotwork::crypto::generate_feed_url(
407 + "",
408 + follower,
409 + 0,
410 + "test-signing-secret-for-integration-tests",
411 + );
412 + let resp = h.client.get(&url).await;
413 + assert!(resp.status.is_success(), "RSS feed: {} {}", resp.status, url);
414 + assert!(
415 + resp.text.contains("Deep Syndicated"),
416 + "following the depth-1 root must reach a depth-3 leaf's item over RSS"
417 + );
418 + }
419 +
343 420 /// A followed tag reaches the signed RSS feed too.
344 421 ///
345 422 /// The RSS handler uses get_followed_items, a different query from the /feed