simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 492f110322442a94b16500601576bd274ded2fcf
parent 62abf675b44c0508a225bcd243e72231f6ba8695
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sun,  7 Jun 2026 23:09:59 +0200

perf: batch story enrichment via WASM guest /enrich endpoint

Replace N+1 tag/author lookups (2 HTTP calls per story) with a
single /enrich?ids=... call that returns tags and author usernames
for all stories in 2 SQL queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Mguests/rust/src/lib.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/handlers/feed.rs | 25++++++++++---------------
Msrc/trailbase.rs | 29+++++++++++++++++++++++++++++
Mtraildepot/wasm/search_guest.wasm | 0
4 files changed, 146 insertions(+), 30 deletions(-)

diff --git a/guests/rust/src/lib.rs b/guests/rust/src/lib.rs @@ -4,16 +4,7 @@ use trailbase_wasm::db::{query, Value}; use trailbase_wasm::http::{HttpError, HttpRoute, Json, Request, StatusCode, routing}; use trailbase_wasm::{Guest, export}; -struct SearchGuest; - -#[derive(serde::Serialize)] -struct SearchResult { - id: i64, - title: String, - text: String, - score: i64, - comment_count: i64, -} +struct ApiGuest; fn as_integer(v: &Value) -> i64 { match v { @@ -30,6 +21,27 @@ fn as_text(v: &Value) -> String { } } +fn as_opt_text(v: &Value) -> Option<String> { + match v { + Value::Text(s) => Some(s.clone()), + _ => None, + } +} + +fn err(msg: impl std::fmt::Display) -> HttpError { + HttpError::message(StatusCode::INTERNAL_SERVER_ERROR, msg.to_string()) +} + +// GET /search?q=...&limit=20 +#[derive(serde::Serialize)] +struct SearchResult { + id: i64, + title: String, + text: String, + score: i64, + comment_count: i64, +} + async fn search_handler(req: Request) -> Result<Json<Vec<SearchResult>>, HttpError> { let q = req .query_param("q") @@ -49,9 +61,9 @@ async fn search_handler(req: Request) -> Result<Json<Vec<SearchResult>>, HttpErr [Value::Text(q), Value::Integer(limit)], ) .await - .map_err(|e| HttpError::message(StatusCode::INTERNAL_SERVER_ERROR, format!("Search failed: {e}")))?; + .map_err(err)?; - let results: Vec<SearchResult> = rows + let results = rows .iter() .map(|row| SearchResult { id: as_integer(&row[0]), @@ -65,10 +77,90 @@ async fn search_handler(req: Request) -> Result<Json<Vec<SearchResult>>, HttpErr Ok(Json(results)) } -impl Guest for SearchGuest { +// GET /enrich?ids=1,2,3 +// Returns tags and author usernames for multiple stories in one round trip. +#[derive(serde::Serialize)] +struct StoryMeta { + story_id: i64, + author_username: Option<String>, + tags: Vec<String>, +} + +async fn enrich_handler(req: Request) -> Result<Json<Vec<StoryMeta>>, HttpError> { + let ids_str = req + .query_param("ids") + .ok_or_else(|| HttpError::message(StatusCode::BAD_REQUEST, "Missing 'ids' parameter"))?; + + let ids: Vec<i64> = ids_str + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + + if ids.is_empty() { + return Ok(Json(vec![])); + } + + // Build placeholders: $1, $2, $3, ... + let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("${}", i)).collect(); + let placeholder_str = placeholders.join(","); + + // Fetch author usernames + let author_sql = format!( + "SELECT s.id, up.username + FROM story s + LEFT JOIN user_profile up ON up.user_id = s.created_by + WHERE s.id IN ({})", + placeholder_str + ); + let params: Vec<Value> = ids.iter().map(|&id| Value::Integer(id)).collect(); + let author_rows = query(&author_sql, params.clone()).await.map_err(err)?; + + // Fetch tags + let tag_sql = format!( + "SELECT sc.news_item_id, c.name + FROM story_category sc + JOIN category c ON c.id = sc.category_id + WHERE sc.news_item_id IN ({})", + placeholder_str + ); + let tag_rows = query(&tag_sql, params).await.map_err(err)?; + + // Build result + let mut result: Vec<StoryMeta> = ids + .iter() + .map(|&id| StoryMeta { + story_id: id, + author_username: None, + tags: vec![], + }) + .collect(); + + for row in &author_rows { + let id = as_integer(&row[0]); + let username = as_opt_text(&row[1]); + if let Some(meta) = result.iter_mut().find(|m| m.story_id == id) { + meta.author_username = username; + } + } + + for row in &tag_rows { + let id = as_integer(&row[0]); + let tag_name = as_text(&row[1]); + if let Some(meta) = result.iter_mut().find(|m| m.story_id == id) { + meta.tags.push(tag_name); + } + } + + Ok(Json(result)) +} + +impl Guest for ApiGuest { fn http_handlers() -> Vec<HttpRoute> { - vec![routing::get("/search", search_handler)] + vec![ + routing::get("/search", search_handler), + routing::get("/enrich", enrich_handler), + ] } } -export!(SearchGuest); +export!(ApiGuest); diff --git a/src/handlers/feed.rs b/src/handlers/feed.rs @@ -87,26 +87,21 @@ async fn fetch_feed( vec![] }; - // Fetch tags for each story and build enriched stories + // Batch fetch tags and authors in 2 SQL queries instead of N+1 + let meta = trailbase::enrich_stories(&state.http_client, &state.trailbase_url, &story_ids) + .await + .inspect_err(|e| tracing::error!("Failed to enrich stories: {}", e)) + .unwrap_or_default(); + let mut enriched_stories = Vec::with_capacity(stories.len()); let base_rank = query.page * PAGE_LIMIT; for (idx, story) in stories.into_iter().enumerate() { - let tags = trailbase::get_categories_for_story(&client, story.id) - .await - .inspect_err(|e| tracing::error!("Failed to get tags for story {}: {}", story.id, e)) + let story_meta = meta.iter().find(|m| m.story_id == story.id); + let tags: Vec<Category> = story_meta + .map(|m| m.tags.iter().map(|name| Category { id: 0, name: Some(name.clone()) }).collect()) .unwrap_or_default(); - - let author_username = if let Some(ref created_by) = story.created_by { - trailbase::get_user_profile_by_id(&client, created_by) - .await - .inspect_err(|e| tracing::error!("Failed to get profile for {}: {}", created_by, e)) - .ok() - .flatten() - .map(|p| p.username) - } else { - None - }; + let author_username = story_meta.and_then(|m| m.author_username.clone()); let time_ago = trailbase::time_ago(&story.published); let user_voted = voted_ids.contains(&story.id); diff --git a/src/trailbase.rs b/src/trailbase.rs @@ -26,6 +26,35 @@ pub struct SearchResult { pub comment_count: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoryMeta { + pub story_id: i64, + pub author_username: Option<String>, + pub tags: Vec<String>, +} + +pub async fn enrich_stories( + http_client: &reqwest::Client, + trailbase_url: &str, + story_ids: &[i64], +) -> Result<Vec<StoryMeta>, ClientError> { + if story_ids.is_empty() { + return Ok(vec![]); + } + let ids_str: String = story_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); + let base = trailbase_url.trim_end_matches('/'); + let url = format!("{}/enrich?ids={}", base, ids_str); + let response = http_client + .get(&url) + .send() + .await + .map_err(|e| ClientError::Auth(format!("Enrich request failed: {}", e)))?; + response + .json() + .await + .map_err(|e| ClientError::Auth(format!("Enrich parse failed: {}", e))) +} + /// Tokens stored in cookies for authenticated requests #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredTokens { diff --git a/traildepot/wasm/search_guest.wasm b/traildepot/wasm/search_guest.wasm Binary files differ.