atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-40: resolve appview media CDN URLs

Orual (Jun 29, 2026, 7:06 PM EDT) 79b576ab 97af9152

+191 -32
+15 -3
.sqlx/query-5780ff57e7ebea7299209efcbdd412988b055e7efb7df66b6d078655b7f8e83f.json .sqlx/query-a67e8c2d9623704ef825f7fed112c483615e25268febd82c231a9af42ca6fefd.json
··· 1 1 { 2 2 "db_name": "SQLite", 3 - "query": "SELECT (SELECT handle FROM identities WHERE did = ?) AS \"handle?\",\n (SELECT display_name FROM profiles WHERE did = ?) AS \"display_name?\"", 3 + "query": "SELECT (SELECT handle FROM identities WHERE did = ?) AS \"handle?\",\n (SELECT display_name FROM profiles WHERE did = ?) AS \"display_name?\",\n (SELECT avatar_json FROM profiles WHERE did = ?) AS \"avatar_json?\"", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 24 24 "name": "display_name" 25 25 } 26 26 } 27 + }, 28 + { 29 + "name": "avatar_json?", 30 + "ordinal": 2, 31 + "type_info": "Text", 32 + "origin": { 33 + "Table": { 34 + "table": "profiles", 35 + "name": "avatar_json" 36 + } 37 + } 27 38 } 28 39 ], 29 40 "parameters": { 30 - "Right": 2 41 + "Right": 3 31 42 }, 32 43 "nullable": [ 44 + true, 33 45 true, 34 46 true 35 47 ] 36 48 }, 37 - "hash": "5780ff57e7ebea7299209efcbdd412988b055e7efb7df66b6d078655b7f8e83f" 49 + "hash": "a67e8c2d9623704ef825f7fed112c483615e25268febd82c231a9af42ca6fefd" 38 50 }
+86 -11
src/appview/tests.rs
··· 49 49 // Seeding uses runtime queries so the committed .sqlx cache stays focused on 50 50 // production SQL. 51 51 52 + fn blob(cid: &str) -> serde_json::Value { 53 + json!({ 54 + "$type": "blob", 55 + "ref": { "$link": cid }, 56 + "mimeType": "image/jpeg", 57 + "size": 1234 58 + }) 59 + } 60 + 61 + fn image(cid: &str, alt: &str) -> serde_json::Value { 62 + json!({ 63 + "alt": alt, 64 + "aspectRatio": { "width": 4, "height": 3 }, 65 + "image": blob(cid) 66 + }) 67 + } 68 + 69 + fn strong_ref(uri: &str) -> serde_json::Value { 70 + json!({ "uri": uri, "cid": "bafyreicid" }) 71 + } 72 + 52 73 async fn seed_thing( 53 74 pool: &SqlitePool, 54 75 did: &str, ··· 72 93 .bind(name) 73 94 .bind(created) 74 95 .bind(now) 75 - .bind(json!({"name": name, "createdAt": "2024-01-01T00:00:00.000Z"}).to_string()) 96 + .bind(json!({ 97 + "name": name, 98 + "license": "CC-BY-4.0", 99 + "cover": [image("bafkreiathingcover", "Thing cover")], 100 + "previews": [image("bafkreiathingpreview", "Thing preview")], 101 + "models": models.iter().map(|m| strong_ref(m)).collect::<Vec<_>>(), 102 + "createdAt": "2024-01-01T00:00:00.000Z" 103 + }).to_string()) 76 104 .execute(pool) 77 105 .await 78 106 .unwrap(); ··· 118 146 .bind(name) 119 147 .bind(now) 120 148 .bind(now) 121 - .bind(json!({"name": name, "createdAt": "2024-01-01T00:00:00.000Z"}).to_string()) 149 + .bind(json!({ 150 + "name": name, 151 + "cover": [image("bafkreiamodelcover", "Model cover")], 152 + "previews": [image("bafkreiamodelpreview", "Model preview")], 153 + "parts": parts.iter().map(|p| strong_ref(p)).collect::<Vec<_>>(), 154 + "createdAt": "2024-01-01T00:00:00.000Z" 155 + }).to_string()) 122 156 .execute(pool) 123 157 .await 124 158 .unwrap(); ··· 150 184 .bind(name) 151 185 .bind(json!({"mimeType": "model/stl", "size": 1, "chunks": []}).to_string()) 152 186 .bind(now) 153 - .bind(json!({"name": name, "file": {"mimeType": "model/stl", "size": 1, "chunks": []}, "createdAt": "2024-01-01T00:00:00.000Z"}).to_string()) 187 + .bind(now) 188 + .bind(json!({ 189 + "name": name, 190 + "file": {"mimeType": "model/stl", "size": 1, "chunks": []}, 191 + "previews": [image("bafkreiapartpreview", "Part preview")], 192 + "createdAt": "2024-01-01T00:00:00.000Z" 193 + }).to_string()) 154 194 .execute(pool) 155 195 .await 156 196 .unwrap(); ··· 171 211 } 172 212 173 213 async fn seed_profile(pool: &SqlitePool, did: &str, display: &str) { 214 + let avatar = blob("bafkreiavatar").to_string(); 174 215 sqlx::query( 175 216 r#"INSERT INTO profiles (did, display_name, description, avatar_json, default_license, 176 217 pronouns, printers_json, links_json, record_json, indexed_at) 177 - VALUES (?, ?, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?) 178 - ON CONFLICT(did) DO UPDATE SET display_name = excluded.display_name, record_json = excluded.record_json"#, 218 + VALUES (?, ?, NULL, ?, NULL, NULL, NULL, NULL, ?, ?) 219 + ON CONFLICT(did) DO UPDATE SET display_name = excluded.display_name, 220 + avatar_json = excluded.avatar_json, record_json = excluded.record_json"#, 179 221 ) 180 222 .bind(did) 181 223 .bind(display) 182 - .bind(json!({"displayName": display}).to_string()) 224 + .bind(avatar) 225 + .bind(json!({"displayName": display, "avatar": blob("bafkreiavatar")}).to_string()) 183 226 .bind(chrono::Utc::now().timestamp_millis()) 184 227 .execute(pool) 185 228 .await ··· 192 235 let m1 = seed_model(&state.pool, DID_A, "m1", "Model One", &[]).await; 193 236 let m2 = seed_model(&state.pool, DID_A, "m2", "Model Two", &[]).await; 194 237 let thing = seed_thing(&state.pool, DID_A, "t1", "Cube", &[&m2, &m1], 3).await; 195 - seed_identity(&state.pool, DID_A, "alice.example").await; 238 + seed_identity(&state.pool, DID_A, "alice.com").await; 239 + seed_profile(&state.pool, DID_A, "Alice").await; 196 240 197 241 let (view, models) = views::get_thing(&state, &thing, None).await.unwrap(); 198 242 assert_eq!(view.uri.as_ref(), thing); 199 243 assert_eq!(view.name.as_str(), "Cube"); 200 244 assert_eq!(view.like_count, 3); 201 245 assert_eq!(view.model_count, 2); 246 + assert_eq!( 247 + view.cover.as_ref().unwrap()[0].thumb.as_ref(), 248 + "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiathingcover@jpeg" 249 + ); 250 + assert_eq!( 251 + view.previews.as_ref().unwrap()[0].fullsize.as_ref(), 252 + "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiathingpreview@jpeg" 253 + ); 254 + assert_eq!( 255 + view.author.avatar.as_ref().unwrap().as_ref(), 256 + "https://cdn.bsky.app/img/avatar/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiavatar@jpeg" 257 + ); 202 258 // record must be populated from record_json (never null). 203 259 assert!( 204 260 serde_json::to_string(&view.record) ··· 210 266 assert_eq!(models[0].uri.as_ref(), m2); 211 267 assert_eq!(models[1].uri.as_ref(), m1); 212 268 // Author handle comes from the identity projection. 213 - assert_eq!(view.author.handle.as_ref(), "alice.example"); 269 + assert_eq!(view.author.handle.as_ref(), "alice.com"); 214 270 } 215 271 216 272 #[tokio::test] ··· 229 285 #[tokio::test] 230 286 async fn get_model_hydrates_parts_and_parent_thing() { 231 287 let state = state().await; 288 + seed_identity(&state.pool, DID_A, "alice.com").await; 232 289 let p1 = seed_part(&state.pool, DID_A, "p1", "Part One").await; 233 290 let p2 = seed_part(&state.pool, DID_A, "p2", "Part Two").await; 234 291 let model = seed_model(&state.pool, DID_A, "m1", "Model", &[&p2, &p1]).await; ··· 237 294 let detail = views::get_model(&state, &model, None).await.unwrap(); 238 295 assert_eq!(detail.model.uri.as_ref(), model); 239 296 assert_eq!(detail.model.part_count, 2); 297 + assert_eq!( 298 + detail.model.cover.as_ref().unwrap()[0].thumb.as_ref(), 299 + "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiamodelcover@jpeg" 300 + ); 240 301 assert_eq!(detail.parts.len(), 2); 241 302 assert_eq!(detail.parts[0].uri.as_ref(), p2); // ordered by position 303 + assert_eq!( 304 + detail.parts[0].previews.as_ref().unwrap()[0].thumb.as_ref(), 305 + "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiapartpreview@jpeg" 306 + ); 242 307 assert_eq!(detail.thing.as_ref().unwrap().uri.as_ref(), thing); 308 + assert!(detail.thing.as_ref().unwrap().previews.is_some()); 243 309 } 244 310 245 311 #[tokio::test] 246 312 async fn author_things_keyset_paginates_by_rkey() { 247 313 let state = state().await; 314 + seed_identity(&state.pool, DID_A, "alice.com").await; 248 315 // rkeys are time-ordered TIDs; ordered rkeys make rkey DESC deterministic. 249 316 seed_thing(&state.pool, DID_A, "3zzzzzzzzzzzz", "t3", &[], 0).await; 250 317 seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await; ··· 266 333 #[tokio::test] 267 334 async fn feed_hot_is_deterministic_with_tied_scores() { 268 335 let state = state().await; 336 + seed_identity(&state.pool, DID_A, "alice.com").await; 269 337 // Two things with identical engagement and near-identical age -> tied score; 270 338 // ordering must still be deterministic via the rkey tie-break. 271 339 seed_thing(&state.pool, DID_A, "3aaaaaaaaaaaa", "a", &[], 5).await; ··· 285 353 #[tokio::test] 286 354 async fn search_things_uses_fts() { 287 355 let state = state().await; 356 + seed_identity(&state.pool, DID_A, "alice.com").await; 288 357 seed_thing(&state.pool, DID_A, "r1", "Calibration Cube", &[], 0).await; 289 358 seed_thing(&state.pool, DID_A, "r2", "Friendly Sphere", &[], 0).await; 290 359 ··· 307 376 #[tokio::test] 308 377 async fn list_filters_non_thing_subjects() { 309 378 let state = state().await; 379 + seed_identity(&state.pool, DID_A, "alice.com").await; 310 380 let thing = seed_thing(&state.pool, DID_A, "t1", "Thing", &[], 0).await; 311 381 let list = "at://did:plc:l/space.polymodel.graph.list/self"; 312 382 let non_thing = "at://did:plc:x/space.polymodel.graph.like/k1"; ··· 325 395 #[tokio::test] 326 396 async fn polymodel_profile_variant_carries_stats() { 327 397 let state = state().await; 328 - seed_identity(&state.pool, DID_A, "alice.example").await; 398 + seed_identity(&state.pool, DID_A, "alice.com").await; 329 399 seed_profile(&state.pool, DID_A, "Alice").await; 330 400 seed_thing(&state.pool, DID_A, "t1", "Thing One", &[], 4).await; 331 401 seed_thing(&state.pool, DID_A, "t2", "Thing Two", &[], 6).await; ··· 335 405 .unwrap() 336 406 .expect("polymodel profile exists"); 337 407 assert_eq!(view.did.as_ref(), DID_A); 338 - assert_eq!(view.handle.as_str(), "alice.example"); 408 + assert_eq!(view.handle.as_str(), "alice.com"); 339 409 assert_eq!( 340 410 view.display_name.as_ref().map(|s| s.as_str()), 341 411 Some("Alice") 342 412 ); 343 413 assert_eq!(view.thing_count, Some(2)); 344 414 assert_eq!(view.total_likes, Some(10)); 415 + assert_eq!( 416 + view.avatar.as_ref().unwrap().as_ref(), 417 + "https://cdn.bsky.app/img/avatar/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreiavatar@jpeg" 418 + ); 345 419 } 346 420 347 421 #[tokio::test] ··· 357 431 async fn union_serializes_type_tag() { 358 432 // The polymodel variant must discriminate on $type without a network fetch. 359 433 let state = state().await; 360 - seed_identity(&state.pool, DID_A, "alice.example").await; 434 + seed_identity(&state.pool, DID_A, "alice.com").await; 361 435 seed_profile(&state.pool, DID_A, "Alice").await; 362 436 let view = views::polymodel_profile_view(&state, &did(DID_A)) 363 437 .await ··· 442 516 #[tokio::test] 443 517 async fn feed_recent_paginates_by_rkey() { 444 518 let state = state().await; 519 + seed_identity(&state.pool, DID_A, "alice.com").await; 445 520 seed_thing(&state.pool, DID_A, "3zzzzzzzzzzzz", "t3", &[], 0).await; 446 521 seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await; 447 522 seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await;
+90 -18
src/appview/views.rs
··· 3 3 //! Records come from the `record_json` column (written by the projection); the 4 4 //! required `record` field on thing/model/part views is built from it via 5 5 //! [`to_data`]. Author handles come from `identities` (refreshed by identity 6 - //! events) and profile display fields from `profiles`. No fake CDN/media data is 7 - //! synthesized: optional image fields are left `None` until blob→URL resolution 8 - //! lands. 6 + //! events) and profile display fields from `profiles`. Authored image/blob fields 7 + //! are deserialized through generated lexicon types and resolved to Bluesky CDN 8 + //! URLs in appview response fields; no fake CDN/media data is synthesized. 9 9 //! 10 10 //! Cursor contract: clients treat `cursor` as opaque. 11 11 //! - Chronological feeds (`getAuthorThings`, `getFeed?algorithm=recent`) keyset ··· 21 21 use jacquard::identity::resolver::IdentityResolver; 22 22 use jacquard_common::deps::smol_str::SmolStr; 23 23 use jacquard_common::types::string::{AtUri, Datetime, Did, Handle, UriValue}; 24 - use jacquard_common::types::value::{Data, to_data}; 24 + use jacquard_common::types::{ 25 + blob::BlobRef, 26 + value::{Data, to_data}, 27 + }; 25 28 use polymodel_api::space_polymodel::library::{ 26 - Actor, FeedItem, FeedView, File, ModelView, PartView, ThingView, ThingViewBasic, ViewerState, 29 + Actor, FeedItem, FeedView, File, Image, ImageView, ModelView, PartView, ThingView, 30 + ThingViewBasic, ViewerState, model::Model, part::Part, thing::Thing, 27 31 }; 28 32 use sqlx::SqlitePool; 29 33 ··· 79 83 .collect::<Option<Vec<_>>>() 80 84 .filter(|v| !v.is_empty()) 81 85 } 86 + fn blob_cdn_url(repo_did: &str, blob_ref: &BlobRef) -> AppResult<UriValue> { 87 + let cid = blob_ref.blob().r#ref.as_str(); 88 + UriValue::new_owned(format!( 89 + "https://cdn.bsky.app/img/feed_thumbnail/plain/{repo_did}/{cid}@jpeg" 90 + )) 91 + .map_err(|e| internal(format!("invalid generated image CDN URL: {e}"))) 92 + } 82 93 94 + fn avatar_cdn_url(repo_did: &str, blob_ref: &BlobRef) -> AppResult<UriValue> { 95 + let cid = blob_ref.blob().r#ref.as_str(); 96 + UriValue::new_owned(format!( 97 + "https://cdn.bsky.app/img/avatar/plain/{repo_did}/{cid}@jpeg" 98 + )) 99 + .map_err(|e| internal(format!("invalid generated avatar CDN URL: {e}"))) 100 + } 101 + 102 + fn image_view(repo_did: &str, image: Image) -> AppResult<ImageView> { 103 + let url = blob_cdn_url(repo_did, &image.image)?; 104 + Ok(ImageView { 105 + alt: Some(image.alt), 106 + aspect_ratio: Some(image.aspect_ratio), 107 + fullsize: url.clone(), 108 + thumb: url, 109 + extra_data: None, 110 + }) 111 + } 112 + 113 + fn image_views_from_images( 114 + repo_did: &str, 115 + images: Option<Vec<Image>>, 116 + ) -> AppResult<Option<Vec<ImageView>>> { 117 + images 118 + .unwrap_or_default() 119 + .into_iter() 120 + .map(|image| image_view(repo_did, image)) 121 + .collect::<AppResult<Vec<_>>>() 122 + .map(|views| if views.is_empty() { None } else { Some(views) }) 123 + } 124 + 125 + fn thing_record(record_json: Option<&str>) -> AppResult<Thing> { 126 + serde_json::from_str(record_json.ok_or_else(|| internal("thing row missing record_json"))?) 127 + .map_err(|e| internal(format!("thing record_json typed parse: {e}"))) 128 + } 129 + 130 + fn model_record(record_json: Option<&str>) -> AppResult<Model> { 131 + serde_json::from_str(record_json.ok_or_else(|| internal("model row missing record_json"))?) 132 + .map_err(|e| internal(format!("model record_json typed parse: {e}"))) 133 + } 134 + 135 + fn part_record(record_json: Option<&str>) -> AppResult<Part> { 136 + serde_json::from_str(record_json.ok_or_else(|| internal("part row missing record_json"))?) 137 + .map_err(|e| internal(format!("part record_json typed parse: {e}"))) 138 + } 139 + 140 + fn avatar_url(repo_did: &str, avatar_json: Option<&str>) -> AppResult<Option<UriValue>> { 141 + let Some(raw) = avatar_json else { 142 + return Ok(None); 143 + }; 144 + let blob_ref: BlobRef = 145 + serde_json::from_str(raw).map_err(|e| internal(format!("avatar_json parse: {e}")))?; 146 + Ok(Some(avatar_cdn_url(repo_did, &blob_ref)?)) 147 + } 83 148 // --------------------------------------------------------------------------- 84 149 // rows 85 150 // --------------------------------------------------------------------------- ··· 159 224 struct AuthorRow { 160 225 handle: Option<String>, 161 226 display_name: Option<String>, 227 + avatar_json: Option<String>, 162 228 } 163 229 let row = db(sqlx::query_as!( 164 230 AuthorRow, 165 231 r#"SELECT (SELECT handle FROM identities WHERE did = ?) AS "handle?", 166 - (SELECT display_name FROM profiles WHERE did = ?) AS "display_name?""#, 232 + (SELECT display_name FROM profiles WHERE did = ?) AS "display_name?", 233 + (SELECT avatar_json FROM profiles WHERE did = ?) AS "avatar_json?""#, 234 + did, 167 235 did, 168 236 did, 169 237 ) 170 238 .fetch_optional(&state.pool) 171 239 .await)?; 172 - let (handle, display_name) = match row { 173 - Some(r) => (r.handle, r.display_name), 174 - None => (None, None), 240 + let (handle, display_name, avatar_json) = match row { 241 + Some(r) => (r.handle, r.display_name, r.avatar_json), 242 + None => (None, None, None), 175 243 }; 176 244 Ok(Actor { 177 245 did: did_of(did)?, ··· 179 247 handle.ok_or_else(|| internal(format!("missing handle for actor {did}")))?, 180 248 )?, 181 249 display_name: display_name.map(s), 182 - avatar: None, // blob→CDN resolution deferred; never synthesize a URL 250 + avatar: avatar_url(did, avatar_json.as_deref())?, 183 251 description: None, 184 252 follower_count: None, 185 253 following_count: None, ··· 245 313 ) -> AppResult<ThingView> { 246 314 let author = actor_for_did(state, &row.did).await?; 247 315 let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; 316 + let record = thing_record(row.record_json.as_deref())?; 248 317 Ok(ThingView { 249 318 uri: at_uri(&row.uri)?, 250 319 cid: s(row.cid), ··· 260 329 model_count: row.model_count, 261 330 part_count: row.part_count, 262 331 viewer, 263 - cover: None, 264 - previews: None, 332 + cover: image_views_from_images(&row.did, record.cover)?, 333 + previews: image_views_from_images(&row.did, record.previews)?, 265 334 derived_from: None, 266 335 intended_to_work_with: None, 267 336 extra_data: None, ··· 269 338 } 270 339 271 340 async fn thing_basic_view(state: &AppState, row: ThingRow) -> AppResult<ThingViewBasic> { 341 + let record = thing_record(row.record_json.as_deref())?; 272 342 Ok(ThingViewBasic { 273 343 uri: at_uri(&row.uri)?, 274 344 cid: s(row.cid), ··· 279 349 save_count: row.save_count, 280 350 model_count: row.model_count, 281 351 part_count: row.part_count, 282 - cover: None, 283 - previews: None, 352 + cover: image_views_from_images(&row.did, record.cover)?, 353 + previews: image_views_from_images(&row.did, record.previews)?, 284 354 tags: smol_vec(row.tags_json.as_deref()), 285 355 viewer: ViewerState::default(), 286 356 extra_data: None, ··· 294 364 ) -> AppResult<ModelView> { 295 365 let author = actor_for_did(state, &row.did).await?; 296 366 let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; 367 + let record = model_record(row.record_json.as_deref())?; 297 368 Ok(ModelView { 298 369 uri: at_uri(&row.uri)?, 299 370 cid: s(row.cid), ··· 304 375 summary: row.summary.map(s), 305 376 part_count: row.part_count, 306 377 viewer, 307 - cover: None, 308 - previews: None, 378 + cover: image_views_from_images(&row.did, record.cover)?, 379 + previews: image_views_from_images(&row.did, record.previews)?, 309 380 extra_data: None, 310 381 }) 311 382 } ··· 317 388 ) -> AppResult<PartView> { 318 389 let author = actor_for_did(state, &row.did).await?; 319 390 let viewer = viewer_state(&state.pool, viewer_did, &row.uri).await?; 391 + let record = part_record(row.record_json.as_deref())?; 320 392 Ok(PartView { 321 393 uri: at_uri(&row.uri)?, 322 394 cid: s(row.cid), ··· 326 398 name: s(row.name), 327 399 format: row.format.map(s), 328 400 file: file_data(Some(row.file_json.as_str()))?, 329 - previews: None, 401 + previews: image_views_from_images(&row.did, record.previews)?, 330 402 viewer, 331 403 extra_data: None, 332 404 }) ··· 842 914 record: to_data(&record_value).map_err(|e| internal(format!("record encode: {e}")))?, 843 915 display_name: row.display_name.map(s), 844 916 description: row.description.map(s), 845 - avatar: None, // blob→CDN resolution deferred; never synthesize a URL 917 + avatar: avatar_url(did.as_ref(), row.avatar_json.as_deref())?, 846 918 default_license: row.default_license.map(s), 847 919 pronouns: row.pronouns.map(s), 848 920 printers: smol_vec(row.printers_json.as_deref()),