max / makenotwork
3 files changed,
+41 insertions,
-9 deletions
| @@ -221,11 +221,25 @@ pub async fn count_followed_feed_items( | |||
| 221 | 221 | ||
| 222 | 222 | /// Get all tag IDs that a user follows, for batch lookup on the discover page. | |
| 223 | 223 | #[tracing::instrument(skip_all)] | |
| 224 | - | pub async fn get_followed_tag_ids(pool: &PgPool, follower_id: UserId) -> Result<HashSet<Uuid>> { | |
| 225 | - | let rows: Vec<(Uuid,)> = sqlx::query_as( | |
| 226 | - | "SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'", | |
| 224 | + | /// Which of `tag_ids` the follower follows. Scoped variant of | |
| 225 | + | /// `get_followed_tag_ids`: the discover facet sidebar only renders ~10 tags and | |
| 226 | + | /// tests membership for those, so fetching the viewer's entire followed-tag set | |
| 227 | + | /// per render was an over-fetch (fuzz 2026-07-06 C5-1). Single `= ANY($2)` | |
| 228 | + | /// roundtrip. Returns an empty set for an empty input. | |
| 229 | + | pub async fn following_subset( | |
| 230 | + | pool: &PgPool, | |
| 231 | + | follower_id: UserId, | |
| 232 | + | tag_ids: &[crate::db::TagId], | |
| 233 | + | ) -> Result<HashSet<crate::db::TagId>> { | |
| 234 | + | if tag_ids.is_empty() { | |
| 235 | + | return Ok(HashSet::new()); | |
| 236 | + | } | |
| 237 | + | let rows: Vec<(crate::db::TagId,)> = sqlx::query_as( | |
| 238 | + | "SELECT target_id FROM follows \ | |
| 239 | + | WHERE follower_id = $1 AND target_type = 'tag' AND target_id = ANY($2)", | |
| 227 | 240 | ) | |
| 228 | 241 | .bind(follower_id) | |
| 242 | + | .bind(tag_ids) | |
| 229 | 243 | .fetch_all(pool) | |
| 230 | 244 | .await?; | |
| 231 | 245 |
| @@ -284,7 +284,19 @@ pub async fn get_tag_ancestors(pool: &PgPool, tag_id: TagId) -> Result<Vec<DbTag | |||
| 284 | 284 | ||
| 285 | 285 | /// Count public items per tag for tree browser display. | |
| 286 | 286 | #[tracing::instrument(skip_all)] | |
| 287 | - | pub async fn get_all_tag_counts(pool: &PgPool) -> Result<std::collections::HashMap<TagId, i64>> { | |
| 287 | + | /// Public item counts for a specific set of tags. Replaced the former | |
| 288 | + | /// `get_all_tag_counts`, which aggregated over the ENTIRE tags×item_tags×items× | |
| 289 | + | /// projects join to build a map the caller only read for the current level's | |
| 290 | + | /// children (fuzz 2026-07-06 C5-2). Mirrors the `= ANY($1)` shape of the | |
| 291 | + | /// sibling `count_children_by_parents` that already runs two lines away. | |
| 292 | + | #[tracing::instrument(skip_all)] | |
| 293 | + | pub async fn item_counts_by_tag( | |
| 294 | + | pool: &PgPool, | |
| 295 | + | tag_ids: &[TagId], | |
| 296 | + | ) -> Result<std::collections::HashMap<TagId, i64>> { | |
| 297 | + | if tag_ids.is_empty() { | |
| 298 | + | return Ok(std::collections::HashMap::new()); | |
| 299 | + | } | |
| 288 | 300 | let rows: Vec<(TagId, i64)> = sqlx::query_as( | |
| 289 | 301 | r#" | |
| 290 | 302 | SELECT t.id, COUNT(p.id) | |
| @@ -292,9 +304,11 @@ pub async fn get_all_tag_counts(pool: &PgPool) -> Result<std::collections::HashM | |||
| 292 | 304 | LEFT JOIN item_tags it ON it.tag_id = t.id | |
| 293 | 305 | LEFT JOIN items i ON i.id = it.item_id AND i.is_public = true AND i.listed = true AND i.deleted_at IS NULL | |
| 294 | 306 | LEFT JOIN projects p ON i.project_id = p.id AND p.is_public = true | |
| 307 | + | WHERE t.id = ANY($1) | |
| 295 | 308 | GROUP BY t.id | |
| 296 | 309 | "#, | |
| 297 | 310 | ) | |
| 311 | + | .bind(tag_ids) | |
| 298 | 312 | .fetch_all(pool) | |
| 299 | 313 | .await?; | |
| 300 | 314 |
| @@ -330,11 +330,10 @@ pub(super) async fn tag_tree( | |||
| 330 | 330 | // Fetch children at this level | |
| 331 | 331 | let children = db::tags::get_child_tags(&state.db, parent_id).await?; | |
| 332 | 332 | ||
| 333 | - | // Fetch item counts for all tags | |
| 334 | - | let tag_counts = db::tags::get_all_tag_counts(&state.db).await?; | |
| 335 | - | ||
| 336 | - | // Batch-fetch child counts for all children (single query instead of N+1) | |
| 333 | + | // Item counts + child counts, both scoped to just this level's children | |
| 334 | + | // rather than aggregating over every tag in the catalog (fuzz 2026-07-06 C5-2). | |
| 337 | 335 | let child_ids: Vec<_> = children.iter().map(|c| c.id).collect(); | |
| 336 | + | let tag_counts = db::tags::item_counts_by_tag(&state.db, &child_ids).await?; | |
| 338 | 337 | let grandchild_counts = db::tags::count_children_by_parents(&state.db, &child_ids).await?; | |
| 339 | 338 | ||
| 340 | 339 | let categories: Vec<TagTreeNode> = children.iter().map(|child| { | |
| @@ -436,8 +435,13 @@ pub(super) async fn discover( | |||
| 436 | 435 | let (type_counts, tag_counts, ai_counts, price_counts) = cached_facets( | |
| 437 | 436 | &state.db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price, | |
| 438 | 437 | ).await?; | |
| 438 | + | // Only the first ~10 tag facets are rendered (see `.take(10)` below), so | |
| 439 | + | // test follow-membership against just those rather than fetching the | |
| 440 | + | // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1). | |
| 439 | 441 | let followed_tag_ids = if let Some(uid) = viewer_id { | |
| 440 | - | db::follows::get_followed_tag_ids(&state.db, uid).await? | |
| 442 | + | let rendered_tag_ids: Vec<_> = | |
| 443 | + | tag_counts.iter().take(10).map(|tc| tc.tag_id).collect(); | |
| 444 | + | db::follows::following_subset(&state.db, uid, &rendered_tag_ids).await? | |
| 441 | 445 | } else { | |
| 442 | 446 | std::collections::HashSet::new() | |
| 443 | 447 | }; |