This repository has no description
0

Configure Feed

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

web,bobbin: enable better sidecar data in enrichResponse

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Aug 1, 2026, 5:32 AM +0300) commit d042d73d parent c1722e12 change-id opkomuwv
+904 -278
+6 -6
Cargo.lock
··· 8421 8421 ] 8422 8422 8423 8423 [[package]] 8424 + name = "triomphe" 8425 + version = "0.1.16" 8426 + source = "registry+https://github.com/rust-lang/crates.io-index" 8427 + checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" 8428 + 8429 + [[package]] 8424 8430 name = "trusted-proxies" 8425 8431 version = "0.0.1" 8426 8432 dependencies = [ 8427 8433 "ipnet", 8428 8434 "thiserror 2.0.18", 8429 8435 ] 8430 - 8431 - [[package]] 8432 - name = "triomphe" 8433 - version = "0.1.16" 8434 - source = "registry+https://github.com/rust-lang/crates.io-index" 8435 - checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" 8436 8436 8437 8437 [[package]] 8438 8438 name = "try-lock"
+1 -1
bobbin/crates/types/Cargo.toml
··· 25 25 [features] 26 26 default = ["sh_tangled"] 27 27 com_atproto = [] 28 + com_bad_example = [] 28 29 sh_tangled = ["com_atproto"] 29 30 streaming = ["jacquard-common/websocket"] 30 -
+318 -101
bobbin/crates/xrpc/src/enrich.rs
··· 8 8 response::Response, 9 9 }; 10 10 use bobbin_types::ids::{EdgeKey, SubjectRef, nsid_static}; 11 + use futures::StreamExt; 11 12 use jacquard_common::DefaultStr; 13 + use jacquard_common::IntoStatic; 12 14 use jacquard_common::types::did::Did; 13 15 use jacquard_common::types::ident::AtIdentifier; 16 + use jacquard_common::types::nsid::Nsid; 14 17 use jacquard_common::types::string::AtUri; 15 - use serde::Deserialize; 18 + use serde::{Deserialize, Serialize}; 16 19 use serde_json::{Map, Value, json}; 17 20 use tower::ServiceExt; 18 21 19 - use crate::recordpath::{parse_record_path, walk_path}; 22 + use crate::recordpath::{RecordPath, walk_path}; 20 23 use crate::{AppState, SubjectShape, XrpcError, mirror_kind, subject_shape}; 21 24 22 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] 23 - #[serde(rename_all = "camelCase")] 24 - pub enum Aggregation { 25 - Count, 26 - DistinctAuthors, 27 - Viewer, 25 + pub const TYPE_COUNT: &str = "sh.tangled.query.enrichResponse#count"; 26 + pub const TYPE_DISTINCT_AUTHORS: &str = "sh.tangled.query.enrichResponse#distinctAuthors"; 27 + pub const TYPE_VIEWER: &str = "sh.tangled.query.enrichResponse#viewer"; 28 + /// vendored from upstream com.bad-example.identity.resolveMiniDoc's output 29 + pub const TYPE_MINIDOC: &str = "com.bad-example.identity.miniDoc"; 30 + 31 + const KNOWN_TYPES: [&str; 4] = [TYPE_COUNT, TYPE_DISTINCT_AUTHORS, TYPE_VIEWER, TYPE_MINIDOC]; 32 + 33 + /// a payload type nsid, with an optional #fragment for lexicon defs. the raw 34 + /// string is kept because it echoes into the data map as the payload key 35 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 36 + pub struct PayloadType { 37 + raw: String, 38 + nsid: Nsid<DefaultStr>, 39 + #[allow(dead_code)] 40 + fragment: Option<String>, 41 + } 42 + 43 + impl PayloadType { 44 + pub fn parse(raw: &str) -> Result<Self, String> { 45 + let (nsid, fragment) = match raw.split_once('#') { 46 + Some((nsid, fragment)) => { 47 + let valid = !fragment.is_empty() 48 + && fragment.starts_with(|c: char| c.is_ascii_alphabetic()) 49 + && fragment.chars().all(|c| c.is_ascii_alphanumeric()); 50 + if !valid { 51 + return Err(format!("invalid fragment #{fragment}")); 52 + } 53 + (nsid, Some(fragment.to_owned())) 54 + } 55 + None => (raw, None), 56 + }; 57 + let nsid = Nsid::new_owned(nsid).map_err(|e| format!("invalid nsid: {e}"))?; 58 + Ok(Self { 59 + raw: raw.to_owned(), 60 + nsid, 61 + fragment, 62 + }) 63 + } 64 + 65 + fn as_str(&self) -> &str { 66 + &self.raw 67 + } 68 + } 69 + 70 + impl<'de> Deserialize<'de> for PayloadType { 71 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 72 + where 73 + D: serde::Deserializer<'de>, 74 + { 75 + let raw = String::deserialize(deserializer)?; 76 + Self::parse(&raw).map_err(serde::de::Error::custom) 77 + } 28 78 } 29 79 30 - impl Aggregation { 31 - fn key(self) -> &'static str { 32 - match self { 33 - Aggregation::Count => "count", 34 - Aggregation::DistinctAuthors => "distinctAuthors", 35 - Aggregation::Viewer => "viewer", 36 - } 80 + impl Serialize for PayloadType { 81 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 82 + where 83 + S: serde::Serializer, 84 + { 85 + serializer.serialize_str(&self.raw) 37 86 } 38 87 } 39 88 40 - #[derive(Debug, Deserialize)] 41 - pub struct LinkDescriptor { 42 - /// constellation link source 43 - source: String, 44 - #[serde(rename = "type")] 45 - aggregation: Aggregation, 89 + /// the paths the edge index can back: a record's reference field, or its 90 + /// authoring repo 91 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 92 + pub enum SourcePath { 93 + Subject, 94 + Repo, 46 95 } 47 96 48 - impl LinkDescriptor { 49 - fn parts(&self) -> Result<(&str, &str, Aggregation), XrpcError> { 50 - match self.source.split_once(':') { 97 + /// a "collection:path" link source. raw echoes into the data map verbatim 98 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 99 + pub struct LinkSource { 100 + raw: String, 101 + collection: Nsid<DefaultStr>, 102 + path: SourcePath, 103 + } 104 + 105 + impl LinkSource { 106 + pub fn parse(raw: &str) -> Result<Self, String> { 107 + let (collection, path) = match raw.split_once(':') { 51 108 Some((collection, path)) if !collection.is_empty() && !path.is_empty() => { 52 - Ok((collection, path, self.aggregation)) 109 + (collection, path) 110 + } 111 + _ => return Err("expected \"collection:path\"".to_owned()), 112 + }; 113 + let collection = 114 + Nsid::new_owned(collection).map_err(|e| format!("invalid collection: {e}"))?; 115 + let path = match path { 116 + "subject" => SourcePath::Subject, 117 + ".repo" => SourcePath::Repo, 118 + _ if path.starts_with('.') => { 119 + return Err("envelope field not index-backed; try .repo".to_owned()); 120 + } 121 + _ => { 122 + return Err( 123 + "path not index-backed; only `subject` and `.repo` are supported".to_owned(), 124 + ); 53 125 } 54 - _ => Err(XrpcError::InvalidParams(format!( 55 - "enrich {:?}: expected \"collection:path\"", 56 - self.source 57 - ))), 58 - } 126 + }; 127 + Ok(Self { 128 + raw: raw.to_owned(), 129 + collection, 130 + path, 131 + }) 132 + } 133 + 134 + fn as_str(&self) -> &str { 135 + &self.raw 136 + } 137 + } 138 + 139 + impl<'de> Deserialize<'de> for LinkSource { 140 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 141 + where 142 + D: serde::Deserializer<'de>, 143 + { 144 + let raw = String::deserialize(deserializer)?; 145 + Self::parse(&raw).map_err(serde::de::Error::custom) 146 + } 147 + } 148 + 149 + impl Serialize for LinkSource { 150 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 151 + where 152 + S: serde::Serializer, 153 + { 154 + serializer.serialize_str(&self.raw) 59 155 } 60 156 } 61 157 62 158 #[derive(Debug, Deserialize)] 159 + pub struct LinkDescriptor { 160 + /// constellation link source 161 + source: LinkSource, 162 + /// nsid of the sidecar payload to produce for this source 163 + #[serde(rename = "type")] 164 + ty: PayloadType, 165 + } 166 + 167 + #[derive(Debug, Deserialize)] 63 168 pub struct EnrichInput { 64 169 xrpc: String, 65 170 #[serde(default)] ··· 76 181 Json(input): Json<EnrichInput>, 77 182 ) -> Result<Json<Value>, XrpcError> { 78 183 for descriptor in &input.enrich { 79 - validate_descriptor(descriptor)?; 80 - if descriptor.aggregation == Aggregation::Viewer && input.viewer.is_none() { 184 + if !KNOWN_TYPES.contains(&descriptor.ty.as_str()) { 185 + return Err(descriptor_error(&descriptor.source, "unknown enrich type")); 186 + } 187 + validate_source(&descriptor.source, &descriptor.ty)?; 188 + if descriptor.ty.as_str() == TYPE_VIEWER && input.viewer.is_none() { 81 189 return Err(descriptor_error( 82 - descriptor, 83 - "viewer aggregation requires a viewer param", 190 + &descriptor.source, 191 + "viewer payloads require a viewer param", 84 192 )); 85 193 } 86 194 } ··· 92 200 match &input.sources { 93 201 Some(sources) => { 94 202 for (i, path) in sources.iter().enumerate() { 95 - let segs = parse_record_path(path) 203 + let path = RecordPath::parse(path) 96 204 .map_err(|e| XrpcError::InvalidParams(format!("sources[{i}] {path:?}: {e}")))?; 97 - for node in walk_path(&segs, [&inner]) { 205 + for node in walk_path(&path, [&inner]) { 98 206 collect_ref(node, &mut refs, &mut seen); 99 207 } 100 208 } 101 209 } 102 210 None => discover_refs(&inner, &mut refs, &mut seen), 103 211 } 212 + // the authority of every at-uri is a reference too: it's embedded in the 213 + // response, and it's the only way record authors get payloads 214 + let authorities: Vec<SubjectRef> = refs 215 + .iter() 216 + .filter_map(|reference| repo_did(reference).map(SubjectRef::Did)) 217 + .collect(); 218 + for authority in authorities { 219 + if seen.insert(authority.clone()) { 220 + refs.push(authority); 221 + } 222 + } 104 223 105 - let mut stats = Map::new(); 106 - for reference in &refs { 107 - let mut per_ref = Map::new(); 108 - for descriptor in &input.enrich { 109 - let (.., aggregation) = descriptor.parts()?; 110 - let Some(kind) = resolve_descriptor(descriptor, reference)? else { 224 + let mut data = Map::new(); 225 + let mut minidoc_targets: Vec<(Did<DefaultStr>, LinkSource)> = Vec::new(); 226 + for descriptor in &input.enrich { 227 + for reference in &refs { 228 + let Some(subject) = applicable_subject(descriptor, reference)? else { 111 229 continue; 112 230 }; 113 - let key = EdgeKey::new(kind, reference.clone()); 114 - let result = match aggregation { 115 - Aggregation::Count => Value::from(state.edges.count(&key)), 116 - Aggregation::DistinctAuthors => { 117 - Value::from(state.edges.count_distinct_authors(&key)) 231 + match descriptor.ty.as_str() { 232 + TYPE_COUNT | TYPE_DISTINCT_AUTHORS | TYPE_VIEWER => { 233 + let kind = edge_kind(&descriptor.source)?; 234 + let key = EdgeKey::new(kind, subject.clone()); 235 + let result = match descriptor.ty.as_str() { 236 + TYPE_COUNT => Value::from(state.edges.count(&key)), 237 + TYPE_DISTINCT_AUTHORS => { 238 + Value::from(state.edges.count_distinct_authors(&key)) 239 + } 240 + _ => { 241 + // viewer presence is validated up front 242 + let viewer = input.viewer.as_ref().expect("viewer param present"); 243 + match state.edges.viewer_source(&key, viewer.as_str()) { 244 + Some(uri) => Value::String(uri.to_string()), 245 + None => Value::Null, 246 + } 247 + } 248 + }; 249 + put( 250 + &mut data, 251 + subject.as_str(), 252 + descriptor.source.as_str(), 253 + descriptor.ty.as_str(), 254 + result, 255 + ); 118 256 } 119 - Aggregation::Viewer => { 120 - // viewer presence is validated up front 121 - let viewer = input.viewer.as_ref().expect("viewer param present"); 122 - match state.edges.viewer_source(&key, viewer.as_str()) { 123 - Some(uri) => Value::String(uri.to_string()), 124 - None => Value::Null, 257 + TYPE_MINIDOC => { 258 + if let Some(target) = repo_did(&subject) { 259 + minidoc_targets.push((target, descriptor.source.clone())); 125 260 } 126 261 } 127 - }; 128 - let entry = per_ref 129 - .entry(descriptor.source.clone()) 130 - .or_insert_with(|| Value::Object(Map::new())); 131 - if let Value::Object(counts) = entry { 132 - counts.insert(aggregation.key().to_owned(), result); 262 + _ => unreachable!("validated up front"), 133 263 } 134 264 } 135 - if !per_ref.is_empty() { 136 - stats.insert(reference.as_str().to_owned(), Value::Object(per_ref)); 137 - } 265 + } 266 + 267 + let docs = resolve_minidocs(&state, minidoc_targets).await; 268 + for (target, source, doc) in docs { 269 + put( 270 + &mut data, 271 + target.as_str(), 272 + source.as_str(), 273 + TYPE_MINIDOC, 274 + doc, 275 + ); 276 + } 277 + 278 + Ok(Json(json!({ "output": inner, "data": data }))) 279 + } 280 + 281 + fn put(data: &mut Map<String, Value>, reference: &str, source: &str, ty: &str, payload: Value) { 282 + let per_ref = data 283 + .entry(reference.to_owned()) 284 + .or_insert_with(|| Value::Object(Map::new())); 285 + let Value::Object(per_ref) = per_ref else { 286 + return; 287 + }; 288 + let per_source = per_ref 289 + .entry(source.to_owned()) 290 + .or_insert_with(|| Value::Object(Map::new())); 291 + if let Value::Object(per_source) = per_source { 292 + per_source.insert(ty.to_owned(), payload); 138 293 } 294 + } 139 295 140 - Ok(Json(json!({ "output": inner, "stats": stats }))) 296 + /// we drop failures, the client falls back to resolveMiniDoc for misses 297 + async fn resolve_minidocs( 298 + state: &AppState, 299 + targets: Vec<(Did<DefaultStr>, LinkSource)>, 300 + ) -> Vec<(Did<DefaultStr>, LinkSource, Value)> { 301 + futures::stream::iter(targets) 302 + .map(|(did, source)| async move { 303 + let doc = state 304 + .slingshot 305 + .resolve_mini_doc(&AtIdentifier::Did(did.clone())) 306 + .await 307 + .ok() 308 + .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok()); 309 + (did, source, doc) 310 + }) 311 + .buffered(crate::FETCH_CONCURRENCY) 312 + .filter_map(|(did, source, doc)| async move { doc.map(|doc| (did, source, doc)) }) 313 + .collect() 314 + .await 141 315 } 142 316 143 - fn descriptor_error(descriptor: &LinkDescriptor, msg: &str) -> XrpcError { 144 - XrpcError::InvalidParams(format!("enrich {}: {msg}", descriptor.source)) 317 + fn descriptor_error(source: &LinkSource, msg: &str) -> XrpcError { 318 + XrpcError::InvalidParams(format!("enrich {}: {msg}", source.as_str())) 145 319 } 146 320 147 - fn validate_descriptor(descriptor: &LinkDescriptor) -> Result<(), XrpcError> { 148 - let (collection, path, _) = descriptor.parts()?; 149 - match path { 150 - "subject" => subject_shape(collection) 321 + fn validate_source(source: &LinkSource, ty: &PayloadType) -> Result<(), XrpcError> { 322 + match source.path { 323 + SourcePath::Subject => subject_shape(source.collection.as_ref()) 151 324 .map(|_| ()) 152 - .ok_or_else(|| descriptor_error(descriptor, "unknown collection")), 153 - ".repo" => mirror_kind(collection) 325 + .ok_or_else(|| descriptor_error(source, "unknown collection")), 326 + // minidocs read the uri's authority straight out of the response, only 327 + // edge payloads need the author index 328 + SourcePath::Repo if ty.as_str() == TYPE_MINIDOC => Ok(()), 329 + SourcePath::Repo => mirror_kind(source.collection.as_ref()) 154 330 .map(|_| ()) 155 - .ok_or_else(|| descriptor_error(descriptor, "collection has no author index")), 156 - _ if path.starts_with('.') => Err(descriptor_error( 157 - descriptor, 158 - "envelope field not index-backed; try .repo", 159 - )), 160 - _ => Err(descriptor_error( 161 - descriptor, 162 - "path not index-backed; only `subject` and `.repo` are supported", 163 - )), 331 + .ok_or_else(|| descriptor_error(source, "collection has no author index")), 164 332 } 165 333 } 166 334 167 - /// the edge kind to look up for one reference, or None if the descriptor's shape 168 - /// doesn't apply to this ref kind, eg. a did-only descriptor asked about an at-uri 169 - fn resolve_descriptor( 335 + fn repo_did(reference: &SubjectRef) -> Option<Did<DefaultStr>> { 336 + match reference { 337 + SubjectRef::Did(did) => Some(did.clone()), 338 + SubjectRef::Uri(uri) => match uri.authority() { 339 + AtIdentifier::Did(did) => Some(did.clone().into_static()), 340 + AtIdentifier::Handle(_) => None, 341 + }, 342 + SubjectRef::Global => None, 343 + } 344 + } 345 + 346 + /// the subject an edge lookup or payload should be keyed by, or None if the 347 + /// descriptor's shape doesn't apply to this ref, eg. a did-only source asked 348 + /// about an at-uri. `.repo` sources normalize to the repo did 349 + fn applicable_subject( 170 350 descriptor: &LinkDescriptor, 171 351 reference: &SubjectRef, 172 - ) -> Result<Option<jacquard_common::types::nsid::Nsid<DefaultStr>>, XrpcError> { 173 - let (collection, path, _) = descriptor.parts()?; 174 - match path { 175 - "subject" => { 176 - let Some((nsid, shape)) = subject_shape(collection) else { 177 - return Err(descriptor_error(descriptor, "unknown collection")); 178 - }; 179 - Ok(shape_accepts(shape, reference).then(|| nsid_static(nsid))) 352 + ) -> Result<Option<SubjectRef>, XrpcError> { 353 + match descriptor.source.path { 354 + SourcePath::Subject => { 355 + let (_, shape) = subject_shape(descriptor.source.collection.as_ref()) 356 + .ok_or_else(|| descriptor_error(&descriptor.source, "unknown collection"))?; 357 + Ok(shape_accepts(shape, reference).then(|| reference.clone())) 180 358 } 181 - ".repo" => { 182 - let Some(kind) = mirror_kind(collection) else { 183 - return Err(descriptor_error( 184 - descriptor, 185 - "collection has no author index", 186 - )); 187 - }; 188 - Ok(matches!(reference, SubjectRef::Did(_)).then(|| nsid_static(kind))) 189 - } 190 - _ => unreachable!("validated up front"), 359 + SourcePath::Repo => Ok(repo_did(reference).map(SubjectRef::Did)), 191 360 } 361 + } 362 + 363 + /// the edge kind behind a source for stats payloads 364 + fn edge_kind(source: &LinkSource) -> Result<Nsid<DefaultStr>, XrpcError> { 365 + let kind = match source.path { 366 + SourcePath::Subject => subject_shape(source.collection.as_ref()) 367 + .map(|(nsid, _)| nsid) 368 + .ok_or_else(|| descriptor_error(source, "unknown collection"))?, 369 + SourcePath::Repo => mirror_kind(source.collection.as_ref()) 370 + .ok_or_else(|| descriptor_error(source, "collection has no author index"))?, 371 + }; 372 + Ok(nsid_static(kind)) 192 373 } 193 374 194 375 /// mismatch means skip, not reject ··· 361 542 collect_ref(&json!("oppi.li"), &mut refs, &mut seen); 362 543 collect_ref(&json!("did:plc:abc"), &mut refs, &mut seen); 363 544 assert_eq!(refs.len(), 3); 545 + } 546 + 547 + #[test] 548 + fn repo_did_normalizes_uri_authorities() { 549 + let uri = SubjectRef::Uri(AtUri::new_owned("at://did:plc:abc/sh.tangled.repo/x").unwrap()); 550 + let bare = SubjectRef::Did(Did::new_owned("did:plc:abc").unwrap()); 551 + assert_eq!( 552 + repo_did(&uri).as_ref().map(|d| d.as_str()), 553 + Some("did:plc:abc") 554 + ); 555 + assert_eq!( 556 + repo_did(&bare).as_ref().map(|d| d.as_str()), 557 + Some("did:plc:abc") 558 + ); 559 + assert_eq!(repo_did(&SubjectRef::Global), None); 560 + } 561 + 562 + #[test] 563 + fn parses_link_sources_and_payload_types() { 564 + let source = LinkSource::parse("sh.tangled.feed.star:subject").unwrap(); 565 + assert_eq!(source.collection.as_ref(), "sh.tangled.feed.star"); 566 + assert_eq!(source.path, SourcePath::Subject); 567 + let source = LinkSource::parse("sh.tangled.graph.follow:.repo").unwrap(); 568 + assert_eq!(source.path, SourcePath::Repo); 569 + assert!(LinkSource::parse("sh.tangled.feed.star").is_err()); 570 + assert!(LinkSource::parse("sh.tangled.feed.star:.rkey").is_err()); 571 + assert!(LinkSource::parse("not an nsid:subject").is_err()); 572 + 573 + let ty = PayloadType::parse(TYPE_MINIDOC).unwrap(); 574 + assert_eq!(ty.nsid.as_ref(), "com.bad-example.identity.miniDoc"); 575 + assert_eq!(ty.fragment.as_deref(), None); 576 + let ty = PayloadType::parse(TYPE_COUNT).unwrap(); 577 + assert_eq!(ty.nsid.as_ref(), "sh.tangled.query.enrichResponse"); 578 + assert_eq!(ty.fragment.as_deref(), Some("count")); 579 + assert!(PayloadType::parse("sh.tangled.query.enrichResponse#").is_err()); 580 + assert!(PayloadType::parse("nope#count").is_err()); 364 581 } 365 582 }
+14 -5
bobbin/crates/xrpc/src/recordpath.rs
··· 18 18 modifier: Option<Modifier>, 19 19 } 20 20 21 + #[derive(Debug, Clone, PartialEq)] 22 + pub(crate) struct RecordPath(Vec<Segment>); 23 + 24 + impl RecordPath { 25 + pub(crate) fn parse(path: &str) -> Result<Self, String> { 26 + parse_record_path(path).map(Self) 27 + } 28 + } 29 + 21 30 pub(crate) fn parse_record_path(path: &str) -> Result<Vec<Segment>, String> { 22 31 let chars: Vec<char> = path.chars().collect(); 23 32 let mut segments = Vec::new(); ··· 116 125 } 117 126 118 127 pub(crate) fn walk_path<'a>( 119 - segments: &[Segment], 128 + path: &RecordPath, 120 129 roots: impl IntoIterator<Item = &'a Value>, 121 130 ) -> Vec<&'a Value> { 122 131 let mut nodes: Vec<&Value> = roots.into_iter().collect(); 123 - for segment in segments { 132 + for segment in &path.0 { 124 133 let mut next: Vec<&Value> = nodes 125 134 .into_iter() 126 135 .filter_map(|node| node.get(&segment.field)) ··· 281 290 ], 282 291 "owner": "did:plc:z", 283 292 }); 284 - let segs = parse_ok("repos[].uri"); 285 - let found = walk_path(&segs, [&doc]); 293 + let path = RecordPath::parse("repos[].uri").unwrap(); 294 + let found = walk_path(&path, [&doc]); 286 295 assert_eq!(found.len(), 2); 287 296 } 288 297 ··· 295 304 ] 296 305 }); 297 306 let segs = parse_ok("items[sh.tangled.repo].uri"); 298 - let found = walk_path(&segs, [&doc]); 307 + let found = walk_path(&RecordPath(segs), [&doc]); 299 308 assert_eq!(found, vec![&json!("at://did:plc:a/sh.tangled.repo/x")]); 300 309 } 301 310 }
+228 -49
bobbin/crates/xrpc/tests/enrich.rs
··· 24 24 25 25 const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i"; 26 26 27 + const COUNT: &str = "sh.tangled.query.enrichResponse#count"; 28 + const DISTINCT_AUTHORS: &str = "sh.tangled.query.enrichResponse#distinctAuthors"; 29 + const VIEWER: &str = "sh.tangled.query.enrichResponse#viewer"; 30 + const MINIDOC: &str = "com.bad-example.identity.miniDoc"; 31 + 27 32 fn at(s: &str) -> AtUri<DefaultStr> { 28 33 AtUri::new_owned(s).unwrap() 29 34 } ··· 177 182 "xrpc": "sh.tangled.repo.listRepos", 178 183 "params": { "subject": owner.as_ref() }, 179 184 "enrich": [ 180 - { "source": "sh.tangled.feed.star:subject", "type": "count" }, 181 - { "source": "sh.tangled.feed.star:subject", "type": "distinctAuthors" }, 182 - { "source": "sh.tangled.repo.issue:subject", "type": "count" } 185 + { "source": "sh.tangled.feed.star:subject", "type": COUNT }, 186 + { "source": "sh.tangled.feed.star:subject", "type": DISTINCT_AUTHORS }, 187 + { "source": "sh.tangled.repo.issue:subject", "type": COUNT } 183 188 ] 184 189 }))) 185 190 .await ··· 189 194 190 195 assert_eq!(status, StatusCode::OK, "{body}"); 191 196 assert_eq!(body["output"]["items"].as_array().unwrap().len(), 1); 192 - let stats = &body["stats"]; 197 + let stats = &body["data"]; 193 198 assert_eq!( 194 - stats["did:plc:limpet"]["sh.tangled.feed.star:subject"]["count"], 199 + stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT], 195 200 json!(3) 196 201 ); 197 202 assert_eq!( 198 - stats["did:plc:limpet"]["sh.tangled.feed.star:subject"]["distinctAuthors"], 203 + stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][DISTINCT_AUTHORS], 199 204 json!(2) 200 205 ); 201 206 assert_eq!( 202 - stats["did:plc:limpet"]["sh.tangled.repo.issue:subject"]["count"], 207 + stats["did:plc:limpet"]["sh.tangled.repo.issue:subject"][COUNT], 203 208 json!(1) 204 209 ); 205 210 assert!(stats["at://did:plc:nel/sh.tangled.repo/reef"].is_null()); ··· 236 241 app.oneshot(enrich_request(json!({ 237 242 "xrpc": "sh.tangled.graph.listFollows", 238 243 "params": { "subject": owner.as_ref() }, 239 - "enrich": [{ "source": "sh.tangled.graph.follow:subject", "type": "count" }, { "source": "sh.tangled.graph.follow:.repo", "type": "count" }] 244 + "enrich": [{ "source": "sh.tangled.graph.follow:subject", "type": COUNT }, { "source": "sh.tangled.graph.follow:.repo", "type": COUNT }] 240 245 }))) 241 246 .await 242 247 .unwrap(), ··· 244 249 .await; 245 250 246 251 assert_eq!(status, StatusCode::OK, "{body}"); 247 - let nel = &body["stats"]["did:plc:nel"]; 252 + let nel = &body["data"]["did:plc:nel"]; 248 253 assert_eq!( 249 - nel["sh.tangled.graph.follow:subject"]["count"], 254 + nel["sh.tangled.graph.follow:subject"][COUNT], 250 255 json!(2), 251 256 "{body}" 252 257 ); 253 258 assert_eq!( 254 - nel["sh.tangled.graph.follow:.repo"]["count"], 259 + nel["sh.tangled.graph.follow:.repo"][COUNT], 255 260 json!(1), 256 261 "{body}" 257 262 ); 258 263 } 259 264 265 + // at-uri authorities join the ref set, so record authors get stats keyed by 266 + // their bare did without appearing as a value anywhere in the response 267 + #[tokio::test] 268 + async fn authorities_of_record_uris_become_refs() { 269 + let h = Harness::new().await; 270 + let owner = did("did:plc:nel"); 271 + for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() { 272 + h.add_edge( 273 + "sh.tangled.graph.follow", 274 + SubjectRef::Did(owner.clone()), 275 + &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")), 276 + ); 277 + h.mount( 278 + &did(fan), 279 + "sh.tangled.graph.follow", 280 + &format!("f{i}"), 281 + follow_body(&owner), 282 + ) 283 + .await; 284 + } 285 + // each fan also follows one other person 286 + for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() { 287 + h.add_edge( 288 + "sh.tangled.graph.follow.by", 289 + SubjectRef::Did(did(fan)), 290 + &at(&format!("at://{fan}/sh.tangled.graph.follow/g{i}")), 291 + ); 292 + } 293 + 294 + let app = router(h.state.clone()); 295 + let (status, body) = json_response( 296 + app.oneshot(enrich_request(json!({ 297 + "xrpc": "sh.tangled.graph.listFollows", 298 + "params": { "subject": owner.as_ref() }, 299 + "enrich": [ 300 + { "source": "sh.tangled.graph.follow:subject", "type": COUNT }, 301 + { "source": "sh.tangled.graph.follow:.repo", "type": COUNT } 302 + ] 303 + }))) 304 + .await 305 + .unwrap(), 306 + ) 307 + .await; 308 + 309 + assert_eq!(status, StatusCode::OK, "{body}"); 310 + for fan in ["did:plc:a", "did:plc:b"] { 311 + let entry = &body["data"][fan]; 312 + assert_eq!( 313 + entry["sh.tangled.graph.follow:subject"][COUNT], 314 + json!(0), 315 + "{body}" 316 + ); 317 + assert_eq!( 318 + entry["sh.tangled.graph.follow:.repo"][COUNT], 319 + json!(1), 320 + "{body}" 321 + ); 322 + } 323 + } 324 + 260 325 #[tokio::test] 261 326 async fn sources_scope_which_refs_get_enriched() { 262 327 let h = Harness::new().await; ··· 269 334 app.oneshot(enrich_request(json!({ 270 335 "xrpc": "sh.tangled.repo.listRepos", 271 336 "params": { "subject": owner.as_ref() }, 272 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "count" }], 337 + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 273 338 "sources": ["items[].value.repoDid"] 274 339 }))) 275 340 .await ··· 277 342 ) 278 343 .await; 279 344 assert_eq!(status, StatusCode::OK, "{body}"); 280 - let stats = &body["stats"]; 345 + let stats = &body["data"]; 281 346 assert_eq!( 282 - stats["did:plc:limpet"]["sh.tangled.feed.star:subject"]["count"], 347 + stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT], 283 348 json!(3) 284 349 ); 285 350 assert_eq!(stats.as_object().unwrap().len(), 1, "{body}"); ··· 290 355 app.oneshot(enrich_request(json!({ 291 356 "xrpc": "sh.tangled.repo.listRepos", 292 357 "params": { "subject": owner.as_ref() }, 293 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "count" }], 358 + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 294 359 "sources": ["items[].value.nope"] 295 360 }))) 296 361 .await ··· 298 363 ) 299 364 .await; 300 365 assert_eq!(status, StatusCode::OK, "{body}"); 301 - assert_eq!(body["stats"], json!({})); 366 + assert_eq!(body["data"], json!({})); 302 367 } 303 368 304 369 #[tokio::test] ··· 309 374 app.oneshot(enrich_request(json!({ 310 375 "xrpc": "sh.tangled.repo.getRepo", 311 376 "params": { "repo": "at://did:plc:nel/sh.tangled.repo/absent" }, 312 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "count" }] 377 + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }] 313 378 }))) 314 379 .await 315 380 .unwrap(), ··· 322 387 #[tokio::test] 323 388 async fn rejects_bad_requests() { 324 389 let h = Harness::new().await; 325 - let cases = [ 390 + // semantic rejections: the descriptor parses, the handler refuses it 391 + let handler_cases = [ 326 392 json!({ "xrpc": "sh.tangled.nope.nope", "enrich": [] }), 327 393 json!({ 328 394 "xrpc": "sh.tangled.repo.countRepos", 329 395 "params": { "subject": "did:plc:nel" }, 330 - "enrich": [{ "source": "sh.tangled.nope:subject", "type": "count" }] 396 + "enrich": [{ "source": "sh.tangled.nope:subject", "type": COUNT }] 397 + }), 398 + json!({ 399 + "xrpc": "sh.tangled.repo.countRepos", 400 + "params": { "subject": "did:plc:nel" }, 401 + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "sh.tangled.query.enrichResponse#bogus" }] 331 402 }), 332 403 json!({ 333 404 "xrpc": "sh.tangled.repo.countRepos", 334 405 "params": { "subject": "did:plc:nel" }, 335 - "enrich": [{ "source": "sh.tangled.feed.star:subject.uri", "type": "count" }] 406 + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 407 + "sources": ["items["] 336 408 }), 409 + ]; 410 + for case in handler_cases { 411 + let app = router(h.state.clone()); 412 + let (status, body) = json_response(app.oneshot(enrich_request(case)).await.unwrap()).await; 413 + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); 414 + assert_eq!(body["error"], json!("InvalidRequest"), "{body}"); 415 + } 416 + 417 + // structural rejections: serde refuses the descriptor before the handler 418 + // sees it, which is plain-text 422 rather than our 400 json body 419 + let serde_cases = [ 337 420 json!({ 338 421 "xrpc": "sh.tangled.repo.countRepos", 339 422 "params": { "subject": "did:plc:nel" }, 340 - "enrich": [{ "source": "sh.tangled.feed.star:.rkey", "type": "count" }] 423 + "enrich": [{ "source": "sh.tangled.feed.star:subject" }] 424 + }), 425 + json!({ 426 + "xrpc": "sh.tangled.repo.countRepos", 427 + "params": { "subject": "did:plc:nel" }, 428 + "enrich": [{ "source": "sh.tangled.feed.star", "type": COUNT }] 341 429 }), 342 430 json!({ 343 431 "xrpc": "sh.tangled.repo.countRepos", 344 432 "params": { "subject": "did:plc:nel" }, 345 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "count" }], 346 - "sources": ["items["] 433 + "enrich": [{ "source": "sh.tangled.feed.star:.rkey", "type": COUNT }] 347 434 }), 348 - // no defaults, a source without a colon is rejected 349 435 json!({ 350 436 "xrpc": "sh.tangled.repo.countRepos", 351 437 "params": { "subject": "did:plc:nel" }, 352 - "enrich": [{ "source": "sh.tangled.feed.star", "type": "count" }] 438 + "enrich": [{ "source": "sh.tangled.feed.star:subject.uri", "type": COUNT }] 353 439 }), 354 440 ]; 355 - for case in cases { 441 + for case in serde_cases { 356 442 let app = router(h.state.clone()); 357 - let (status, body) = json_response(app.oneshot(enrich_request(case)).await.unwrap()).await; 358 - assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); 359 - assert_eq!(body["error"], json!("InvalidRequest"), "{body}"); 443 + let response = app.oneshot(enrich_request(case)).await.unwrap(); 444 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 360 445 } 361 - 362 - // serde rejects a missing type before the handler sees it 363 - // that returns plain-text 422, not our 400 json body 364 - let app = router(h.state.clone()); 365 - let response = app 366 - .oneshot(enrich_request(json!({ 367 - "xrpc": "sh.tangled.repo.countRepos", 368 - "params": { "subject": "did:plc:nel" }, 369 - "enrich": [{ "source": "sh.tangled.feed.star:subject" }] 370 - }))) 371 - .await 372 - .unwrap(); 373 - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 374 446 } 375 447 376 448 #[tokio::test] ··· 395 467 let no_viewer = json!({ 396 468 "xrpc": "sh.tangled.repo.listRepos", 397 469 "params": { "subject": owner.as_ref() }, 398 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "viewer" }] 470 + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }] 399 471 }); 400 472 let (status, _) = json_response( 401 473 app.clone() ··· 410 482 let starred_viewer = json!({ 411 483 "xrpc": "sh.tangled.repo.listRepos", 412 484 "params": { "subject": owner.as_ref() }, 413 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "viewer" }], 485 + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }], 414 486 "viewer": "did:plc:nel" 415 487 }); 416 488 let (status, resp) = json_response( ··· 421 493 ) 422 494 .await; 423 495 assert_eq!(status, StatusCode::OK); 424 - let stats = &resp["stats"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; 496 + let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; 425 497 assert_eq!( 426 - stats["viewer"], 498 + stats[VIEWER], 427 499 json!("at://did:plc:nel/sh.tangled.feed.star/r99") 428 500 ); 429 501 ··· 431 503 let other_viewer = json!({ 432 504 "xrpc": "sh.tangled.repo.listRepos", 433 505 "params": { "subject": owner.as_ref() }, 434 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "viewer" }], 506 + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }], 435 507 "viewer": "did:plc:someoneelse" 436 508 }); 437 509 let (status, resp) = 438 510 json_response(app.oneshot(enrich_request(other_viewer)).await.unwrap()).await; 439 511 assert_eq!(status, StatusCode::OK); 440 - let stats = &resp["stats"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; 441 - assert_eq!(stats["viewer"], Value::Null); 512 + let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; 513 + assert_eq!(stats[VIEWER], Value::Null); 514 + } 515 + 516 + #[tokio::test] 517 + async fn minidoc_payloads_resolve_record_authors() { 518 + let h = Harness::new().await; 519 + let owner = did("did:plc:nel"); 520 + for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() { 521 + h.add_edge( 522 + "sh.tangled.graph.follow", 523 + SubjectRef::Did(owner.clone()), 524 + &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")), 525 + ); 526 + h.mount( 527 + &did(fan), 528 + "sh.tangled.graph.follow", 529 + &format!("f{i}"), 530 + follow_body(&owner), 531 + ) 532 + .await; 533 + } 534 + Mock::given(method("GET")) 535 + .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 536 + .and(query_param("identifier", "did:plc:a")) 537 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 538 + "did": "did:plc:a", 539 + "handle": "a.example.com", 540 + "pds": "https://pds.example.com" 541 + }))) 542 + .mount(&h.server) 543 + .await; 544 + Mock::given(method("GET")) 545 + .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 546 + .and(query_param("identifier", "did:plc:b")) 547 + .respond_with(ResponseTemplate::new(404)) 548 + .mount(&h.server) 549 + .await; 550 + 551 + let app = router(h.state.clone()); 552 + let (status, body) = json_response( 553 + app.oneshot(enrich_request(json!({ 554 + "xrpc": "sh.tangled.graph.listFollows", 555 + "params": { "subject": owner.as_ref() }, 556 + "enrich": [{ "source": "sh.tangled.graph.follow:.repo", "type": MINIDOC }] 557 + }))) 558 + .await 559 + .unwrap(), 560 + ) 561 + .await; 562 + 563 + assert_eq!(status, StatusCode::OK, "{body}"); 564 + assert_eq!( 565 + body["data"]["did:plc:a"]["sh.tangled.graph.follow:.repo"][MINIDOC]["handle"], 566 + json!("a.example.com") 567 + ); 568 + // resolution failures are dropped, the client falls back for misses 569 + assert!(body["data"]["did:plc:b"].is_null(), "{body}"); 570 + // the profile owner authored nothing here, so it earns no minidoc 571 + assert!(body["data"]["did:plc:nel"].is_null(), "{body}"); 572 + } 573 + 574 + #[tokio::test] 575 + async fn minidoc_repo_sources_skip_the_author_index() { 576 + let h = Harness::new().await; 577 + let owner = did("did:plc:nel"); 578 + let repo_did = did("did:plc:limpet"); 579 + repo_fixture(&h, &owner, &repo_did).await; 580 + Mock::given(method("GET")) 581 + .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 582 + .and(query_param("identifier", "did:plc:nel")) 583 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 584 + "did": "did:plc:nel", 585 + "handle": "nel.example.com" 586 + }))) 587 + .mount(&h.server) 588 + .await; 589 + 590 + let app = router(h.state.clone()); 591 + // sh.tangled.repo has no author mirror; stats would 400, minidocs must not 592 + let (status, body) = json_response( 593 + app.oneshot(enrich_request(json!({ 594 + "xrpc": "sh.tangled.repo.listRepos", 595 + "params": { "subject": owner.as_ref() }, 596 + "enrich": [{ "source": "sh.tangled.repo:.repo", "type": MINIDOC }] 597 + }))) 598 + .await 599 + .unwrap(), 600 + ) 601 + .await; 602 + 603 + assert_eq!(status, StatusCode::OK, "{body}"); 604 + assert_eq!( 605 + body["data"]["did:plc:nel"]["sh.tangled.repo:.repo"][MINIDOC]["handle"], 606 + json!("nel.example.com") 607 + ); 608 + 609 + let app = router(h.state.clone()); 610 + let (status, body) = json_response( 611 + app.oneshot(enrich_request(json!({ 612 + "xrpc": "sh.tangled.repo.listRepos", 613 + "params": { "subject": owner.as_ref() }, 614 + "enrich": [{ "source": "sh.tangled.repo:.repo", "type": COUNT }] 615 + }))) 616 + .await 617 + .unwrap(), 618 + ) 619 + .await; 620 + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); 442 621 }
+27
lexicons/com/bad-example/identity/miniDoc.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.bad-example.identity.miniDoc", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Minimal identity document, as returned by com.bad-example.identity.resolveMiniDoc.", 8 + "required": ["did", "handle"], 9 + "properties": { 10 + "did": { 11 + "type": "string", 12 + "format": "did", 13 + "description": "DID of the identity." 14 + }, 15 + "handle": { 16 + "type": "string", 17 + "format": "handle", 18 + "description": "Handle of the identity." 19 + }, 20 + "pds": { 21 + "type": "string", 22 + "description": "PDS endpoint, when known." 23 + } 24 + } 25 + } 26 + } 27 + }
+21 -25
lexicons/query/enrichResponse.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "procedure", 7 - "description": "like calling an xrpc query directly, but the response comes back with a stats sidecar: aggregate counts (stars, follows, issues...) for every did / at-uri / strong-ref found in the response. counts are computed synchronously from the server's local index, so there are no pending states to resolve later. any GET query the server implements can be hydrated, including queries it proxies elsewhere.", 7 + "description": "Run an XRPC query and hydrate its response with a data sidecar: typed payloads for every did / at-uri / strong-ref found in the response.", 8 8 "input": { 9 9 "encoding": "application/json", 10 10 "schema": { 11 11 "type": "object", 12 - "required": [ 13 - "xrpc", 14 - "enrich" 15 - ], 12 + "required": ["xrpc", "enrich"], 16 13 "properties": { 17 14 "xrpc": { 18 15 "type": "string", ··· 29 26 "type": "ref", 30 27 "ref": "#linkDescriptor" 31 28 }, 32 - "description": "Counts to compute for each found reference. A descriptor only applies to references it can meaningfully point at: a did can carry author-side counts (.repo), an at-uri can carry subject-side counts." 29 + "description": "Payloads to compute for each found reference." 33 30 }, 34 31 "sources": { 35 32 "type": "array", 36 33 "items": { 37 34 "type": "string" 38 35 }, 39 - "description": "RecordPaths into the inner response restricting which references get counted. When omitted, every did / at-uri / strong-ref in the response is counted. Paths that match nothing yield empty stats rather than an error." 36 + "description": "Record paths into the inner response restricting which references get payloads. All references are enriched when omitted." 40 37 }, 41 38 "viewer": { 42 39 "type": "string", 43 40 "format": "did", 44 - "description": "DID whose own relation to each reference is looked up for viewer-type descriptors, e.g. whether this did starred the found repo and with which record. Required when any enrich descriptor uses type viewer." 41 + "description": "DID whose own relation to each reference is looked up for viewer payloads. Required when any descriptor requests one." 45 42 } 46 43 } 47 44 } ··· 50 47 "encoding": "application/json", 51 48 "schema": { 52 49 "type": "object", 53 - "required": [ 54 - "output", 55 - "stats" 56 - ], 50 + "required": ["output", "data"], 57 51 "properties": { 58 52 "output": { 59 53 "type": "unknown", 60 54 "description": "The inner query's response, unmodified." 61 55 }, 62 - "stats": { 56 + "data": { 63 57 "type": "unknown", 64 - "description": "Reference value (did or at-uri) -> link source (echoed verbatim from the request) -> aggregation results. Example: stats[\"did:plc:...\"][\"sh.tangled.graph.follow:subject\"] = {\"count\": 12, \"distinctAuthors\": 11}." 58 + "description": "Reference (did or at-uri) -> link source -> payload type nsid -> payload. References no descriptor applies to are absent." 65 59 } 66 60 } 67 61 } ··· 69 63 "errors": [ 70 64 { 71 65 "name": "UnknownQuery", 72 - "description": "The xrpc parameter does not name a query this server can execute." 66 + "description": "The xrpc parameter does not name a query this server can execute" 73 67 }, 74 68 { 75 69 "name": "InvalidSourcePath", 76 - "description": "A sources entry is not valid RecordPath syntax." 70 + "description": "A sources entry is not a valid record path" 77 71 }, 78 72 { 79 73 "name": "InvalidLinkDescriptor", 80 - "description": "An enrich entry names an unknown collection, or a path the server's index does not support." 74 + "description": "An enrich entry names an unknown payload type, collection, or path" 81 75 } 82 76 ] 83 77 }, 84 78 "linkDescriptor": { 85 79 "type": "object", 86 - "required": [ 87 - "source", 88 - "type" 89 - ], 90 - "description": "one aggregate count: records whose reference field equals the found reference, addressed by a constellation-style link source (\"collection:recordpath\"). envelope paths (.repo etc.) address the record's own metadata instead of its contents.", 80 + "required": ["source", "type"], 81 + "description": "One sidecar payload, computed from the records at a link source whose reference equals the found reference.", 91 82 "properties": { 92 83 "source": { 93 84 "type": "string", 94 - "description": "Link source: the collection whose records are counted, a colon, then a RecordPath naming the reference field (e.g. sh.tangled.feed.star:subject, sh.tangled.feed.star:subject.uri), or an envelope field: .repo (authoring repo), .collection, .rkey, or bare . (the record's own at-uri)." 85 + "description": "Collection whose records are linked, a colon, then an index-backed path (subject or .repo)." 95 86 }, 96 87 "type": { 97 88 "type": "string", 98 - "knownValues": ["count", "distinctAuthors", "viewer"], 99 - "description": "What to compute over matching records: how many records (count), how many distinct authors (distinctAuthors), or the viewer's own record among the matches (viewer, requires the top-level viewer param). Results land in the stats sidecar at stats[ref][source][type]." 89 + "knownValues": [ 90 + "sh.tangled.query.enrichResponse#count", 91 + "sh.tangled.query.enrichResponse#distinctAuthors", 92 + "sh.tangled.query.enrichResponse#viewer", 93 + "com.bad-example.identity.miniDoc" 94 + ], 95 + "description": "NSID of the payload type to produce, which also defines the payload's shape. Results land at data[ref][source][type]." 100 96 } 101 97 } 102 98 }
+31 -13
web/src/lib/api/enrich.ts
··· 1 1 import type { BobbinContext, XrpcRequestInit } from "./client"; 2 2 import type { Nsid } from "@atcute/lexicons/syntax"; 3 + import type { MiniDoc } from "./identity"; 3 4 import { jsonPost } from "./_request"; 4 5 6 + // payload types, also the keys payloads land under in the data sidecar 7 + export const TYPE_COUNT = "sh.tangled.query.enrichResponse#count"; 8 + export const TYPE_DISTINCT_AUTHORS = "sh.tangled.query.enrichResponse#distinctAuthors"; 9 + export const TYPE_VIEWER = "sh.tangled.query.enrichResponse#viewer"; 10 + export const TYPE_MINIDOC = "com.bad-example.identity.miniDoc"; 11 + 5 12 export type RecordPath = string & {}; 6 13 export type EnvelopePath = ".repo" | ".collection" | ".rkey" | "."; 7 14 export type LinkSource = `${Nsid}:${RecordPath | EnvelopePath}`; 8 15 9 16 export interface LinkDescriptor { 10 17 source: LinkSource; 11 - type: "count" | "distinctAuthors" | "viewer"; 18 + type: string; 12 19 } 13 20 14 - // ref is did or at-uri, source echoes the descriptor string, viewer holds the caller's own record uri if requested, null if none exists 15 - export interface StatsLeaf { 16 - count?: number; 17 - distinctAuthors?: number; 18 - viewer?: string | null; 19 - } 20 - export type Stats = Record<string, Record<LinkSource, StatsLeaf>>; 21 + // data[ref][source][type] = payload, payload shape depends on the type 22 + export type Sidecar = Record<string, Record<string, Record<string, unknown>>>; 23 + 21 24 export interface Enriched<O> { 22 25 output: O; 23 - stats: Stats; 26 + data: Sidecar; 24 27 } 25 28 26 29 export interface EnrichRequest { ··· 37 40 init?: XrpcRequestInit 38 41 ): Promise<Enriched<O>> => jsonPost<Enriched<O>>(ctx, "sh.tangled.query.enrichResponse", req, init); 39 42 40 - export const countOf = (stats: Stats, ref: string | undefined, source: LinkSource): number => 41 - (ref !== undefined && stats[ref]?.[source]?.count) || 0; 43 + export const countOf = (data: Sidecar, ref: string | undefined, source: LinkSource): number => 44 + (ref !== undefined && (data[ref]?.[source]?.[TYPE_COUNT] as number | undefined)) || 0; 45 + 46 + export const distinctAuthorsOf = ( 47 + data: Sidecar, 48 + ref: string | undefined, 49 + source: LinkSource 50 + ): number => 51 + (ref !== undefined && (data[ref]?.[source]?.[TYPE_DISTINCT_AUTHORS] as number | undefined)) || 0; 42 52 43 53 // undefined means no viewer or inapplicable descriptor, null means no matching record 44 54 export const viewerUriOf = ( 45 - stats: Stats, 55 + data: Sidecar, 46 56 ref: string | undefined, 47 57 source: LinkSource 48 - ): string | null | undefined => (ref !== undefined ? stats[ref]?.[source]?.viewer : undefined); 58 + ): string | null | undefined => 59 + ref !== undefined ? (data[ref]?.[source]?.[TYPE_VIEWER] as string | null | undefined) : undefined; 60 + 61 + export const miniDocOf = ( 62 + data: Sidecar, 63 + did: string | undefined, 64 + source: LinkSource 65 + ): MiniDoc | undefined => 66 + did !== undefined ? (data[did]?.[source]?.[TYPE_MINIDOC] as MiniDoc | undefined) : undefined;
+3
web/src/lib/api/lexicons/index.ts
··· 1 + export * as ShTangledActorDefs from "./types/sh/tangled/actor/defs.js"; 1 2 export * as ShTangledActorGetProfile from "./types/sh/tangled/actor/getProfile.js"; 2 3 export * as ShTangledActorGetProfiles from "./types/sh/tangled/actor/getProfiles.js"; 3 4 export * as ShTangledActorProfile from "./types/sh/tangled/actor/profile.js"; ··· 16 17 export * as ShTangledFeedCountStars from "./types/sh/tangled/feed/countStars.js"; 17 18 export * as ShTangledFeedCountStarsBy from "./types/sh/tangled/feed/countStarsBy.js"; 18 19 export * as ShTangledFeedGetStar from "./types/sh/tangled/feed/getStar.js"; 20 + export * as ShTangledFeedGetTimeline from "./types/sh/tangled/feed/getTimeline.js"; 19 21 export * as ShTangledFeedListComments from "./types/sh/tangled/feed/listComments.js"; 20 22 export * as ShTangledFeedListCommentsBy from "./types/sh/tangled/feed/listCommentsBy.js"; 21 23 export * as ShTangledFeedListReactions from "./types/sh/tangled/feed/listReactions.js"; ··· 116 118 export * as ShTangledRepoCountPullsBy from "./types/sh/tangled/repo/countPullsBy.js"; 117 119 export * as ShTangledRepoCountRepos from "./types/sh/tangled/repo/countRepos.js"; 118 120 export * as ShTangledRepoCreate from "./types/sh/tangled/repo/create.js"; 121 + export * as ShTangledRepoDefs from "./types/sh/tangled/repo/defs.js"; 119 122 export * as ShTangledRepoDelete from "./types/sh/tangled/repo/delete.js"; 120 123 export * as ShTangledRepoDeleteBranch from "./types/sh/tangled/repo/deleteBranch.js"; 121 124 export * as ShTangledRepoDescribeRepo from "./types/sh/tangled/repo/describeRepo.js";
+75
web/src/lib/api/lexicons/types/sh/tangled/publicKey/listKeys.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _listItemSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("sh.tangled.publicKey.listKeys#listItem"), 8 + ), 9 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), 10 + uri: /*#__PURE__*/ v.resourceUriString(), 11 + /** 12 + * Embedded sh.tangled.publicKey record 13 + */ 14 + value: /*#__PURE__*/ v.unknown(), 15 + }); 16 + const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.publicKey.listKeys", { 17 + params: /*#__PURE__*/ v.object({ 18 + /** 19 + * Pagination cursor 20 + */ 21 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 22 + /** 23 + * @minimum 1 24 + * @maximum 1000 25 + * @default 50 26 + */ 27 + limit: /*#__PURE__*/ v.optional( 28 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 29 + /*#__PURE__*/ v.integerRange(1, 1000), 30 + ]), 31 + 50, 32 + ), 33 + /** 34 + * Sort direction by createdAt. 35 + * @default "desc" 36 + */ 37 + order: /*#__PURE__*/ v.optional( 38 + /*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>(), 39 + "desc", 40 + ), 41 + /** 42 + * Owner DID whose public-key records to list. 43 + */ 44 + subject: /*#__PURE__*/ v.didString(), 45 + }), 46 + output: { 47 + type: "lex", 48 + schema: /*#__PURE__*/ v.object({ 49 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 50 + get items() { 51 + return /*#__PURE__*/ v.array(listItemSchema); 52 + }, 53 + }), 54 + }, 55 + }); 56 + 57 + type listItem$schematype = typeof _listItemSchema; 58 + type main$schematype = typeof _mainSchema; 59 + 60 + export interface listItemSchema extends listItem$schematype {} 61 + export interface mainSchema extends main$schematype {} 62 + 63 + export const listItemSchema = _listItemSchema as listItemSchema; 64 + export const mainSchema = _mainSchema as mainSchema; 65 + 66 + export interface ListItem extends v.InferInput<typeof listItemSchema> {} 67 + 68 + export interface $params extends v.InferInput<mainSchema["params"]> {} 69 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 70 + 71 + declare module "@atcute/lexicons/ambient" { 72 + interface XRPCQueries { 73 + "sh.tangled.publicKey.listKeys": mainSchema; 74 + } 75 + }
+14 -10
web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts
··· 7 7 /*#__PURE__*/ v.literal("sh.tangled.query.enrichResponse#linkDescriptor"), 8 8 ), 9 9 /** 10 - * Link source: the collection whose records are counted, a colon, then a RecordPath naming the reference field (e.g. sh.tangled.feed.star:subject, sh.tangled.feed.star:subject.uri), or an envelope field: .repo (authoring repo), .collection, .rkey, or bare . (the record's own at-uri). 10 + * Link source: collection whose records are linked, a colon, then an index-backed path (subject or .repo). 11 11 */ 12 12 source: /*#__PURE__*/ v.string(), 13 13 /** 14 - * What to compute over matching records: how many records (count), how many distinct authors (distinctAuthors), or the viewer's own record among the matches (viewer, requires the top-level viewer param). Results land in the stats sidecar at stats[ref][source][type]. 14 + * NSID of the payload type to produce, which also defines the payload's shape. Results land at data[ref][source][type]. 15 15 */ 16 16 type: /*#__PURE__*/ v.string< 17 - "count" | "distinctAuthors" | "viewer" | (string & {}) 17 + | "com.bad-example.identity.miniDoc" 18 + | "sh.tangled.query.enrichResponse#count" 19 + | "sh.tangled.query.enrichResponse#distinctAuthors" 20 + | "sh.tangled.query.enrichResponse#viewer" 21 + | (string & {}) 18 22 >(), 19 23 }); 20 24 const _mainSchema = /*#__PURE__*/ v.procedure( ··· 25 29 type: "lex", 26 30 schema: /*#__PURE__*/ v.object({ 27 31 /** 28 - * Counts to compute for each found reference. A descriptor only applies to references it can meaningfully point at: a did can carry author-side counts (.repo), an at-uri can carry subject-side counts. 32 + * Payloads to compute for each found reference. 29 33 */ 30 34 get enrich() { 31 35 return /*#__PURE__*/ v.array(linkDescriptorSchema); ··· 35 39 */ 36 40 params: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), 37 41 /** 38 - * RecordPaths into the inner response restricting which references get counted. When omitted, every did / at-uri / strong-ref in the response is counted. Paths that match nothing yield empty stats rather than an error. 42 + * Record paths into the inner response restricting which references get payloads. All references are enriched when omitted. 39 43 */ 40 44 sources: /*#__PURE__*/ v.optional( 41 45 /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 42 46 ), 43 47 /** 44 - * DID whose own relation to each reference is looked up for viewer-type descriptors, e.g. whether this did starred the found repo and with which record. Required when any enrich descriptor uses type viewer. 48 + * DID whose own relation to each reference is looked up for viewer payloads. Required when any descriptor requests one. 45 49 */ 46 50 viewer: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), 47 51 /** ··· 54 58 type: "lex", 55 59 schema: /*#__PURE__*/ v.object({ 56 60 /** 57 - * The inner query's response, unmodified. 61 + * Reference (did or at-uri) -> link source -> payload type nsid -> payload. References no descriptor applies to are absent. 58 62 */ 59 - output: /*#__PURE__*/ v.unknown(), 63 + data: /*#__PURE__*/ v.unknown(), 60 64 /** 61 - * Reference value (did or at-uri) -> link source (echoed verbatim from the request) -> aggregation results. Example: stats["did:plc:..."]["sh.tangled.graph.follow:subject"] = {"count": 12, "distinctAuthors": 11}. 65 + * The inner query's response, unmodified. 62 66 */ 63 - stats: /*#__PURE__*/ v.unknown(), 67 + output: /*#__PURE__*/ v.unknown(), 64 68 }), 65 69 }, 66 70 },
+107 -48
web/src/lib/components/profile/pages.ts
··· 1 1 // page fetchers for the profile tabs, shared between the route load (first 2 - // page) and the tab components (load more). pass a shared IdentityCache to 3 - // dedupe across pages 2 + // page) and the tab components (load more). identities come from the enrich 3 + // sidecar's minidoc payloads, resolveMiniDoc only fires for sidecar misses 4 4 5 5 import type { BobbinContext } from "$lib/api/client"; 6 6 import type { Did } from "@atcute/lexicons/syntax"; 7 - import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; 7 + import { 8 + enrich, 9 + countOf, 10 + viewerUriOf, 11 + miniDocOf, 12 + TYPE_COUNT, 13 + TYPE_VIEWER, 14 + TYPE_MINIDOC, 15 + type Sidecar, 16 + type LinkDescriptor, 17 + type LinkSource 18 + } from "$lib/api/enrich"; 8 19 import { fetchPage } from "$lib/api/pagination"; 9 - import { IdentityCache } from "$lib/api/identity"; 20 + import { IdentityCache, type MiniDoc } from "$lib/api/identity"; 10 21 import type { RecordView, RepoRecord } from "$lib/api/records"; 11 22 import type { SearchPage } from "$lib/api/search"; 12 23 import { didFromUri, rkeyFromUri } from "$lib/api/uri"; ··· 35 46 cursor?: string; 36 47 }; 37 48 38 - const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; 39 - const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; 49 + const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: TYPE_COUNT }; 50 + const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: TYPE_VIEWER }; 40 51 const FOLLOW_STATS: LinkDescriptor[] = [ 41 - { source: "sh.tangled.graph.follow:subject", type: "count" }, 42 - { source: "sh.tangled.graph.follow:.repo", type: "count" } 52 + { source: "sh.tangled.graph.follow:subject", type: TYPE_COUNT }, 53 + { source: "sh.tangled.graph.follow:.repo", type: TYPE_COUNT } 43 54 ]; 44 - const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; 55 + const FOLLOW_VIEWER: LinkDescriptor = { 56 + source: "sh.tangled.graph.follow:subject", 57 + type: TYPE_VIEWER 58 + }; 59 + const FOLLOWER_DOCS: LinkDescriptor = { 60 + source: "sh.tangled.graph.follow:.repo", 61 + type: TYPE_MINIDOC 62 + }; 63 + const FOLLOWING_DOCS: LinkDescriptor = { 64 + source: "sh.tangled.graph.follow:subject", 65 + type: TYPE_MINIDOC 66 + }; 67 + const REPO_OWNER_DOCS: LinkDescriptor = { source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC }; 68 + const STAR_SUBJECT_DOCS: LinkDescriptor = { 69 + source: "sh.tangled.feed.star:subject", 70 + type: TYPE_MINIDOC 71 + }; 72 + const VOUCHER_DOCS: LinkDescriptor = { source: "sh.tangled.graph.vouch:.repo", type: TYPE_MINIDOC }; 45 73 46 74 const starDescriptors = (viewerDid: string | undefined) => 47 75 viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; ··· 59 87 }; 60 88 }; 61 89 62 - const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { 90 + const resolveRepoCard = (item: ListItem, ownerHandle: string, data: Sidecar): RepoCardData => { 63 91 const repo = toRepoCard(item, ownerHandle); 64 92 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 65 - const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); 66 - const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); 93 + const stars = countOf(data, repo.repoDid, STAR_COUNT.source); 94 + const viewerUri = viewerUriOf(data, repo.repoDid, STAR_VIEWER.source); 67 95 return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; 68 96 }; 69 97 98 + const docOrResolve = ( 99 + data: Sidecar, 100 + source: LinkSource, 101 + cache: IdentityCache, 102 + did: string 103 + ): Promise<MiniDoc | null> => { 104 + const doc = miniDocOf(data, did, source); 105 + return doc ? Promise.resolve(doc) : cache.resolve(did).catch(() => null); 106 + }; 107 + 70 108 const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 71 109 const value = item.value as ShTangledString.Main; 72 110 return { ··· 79 117 }; 80 118 }; 81 119 82 - // the stats sidecar already carries follower counts and viewer status, the 120 + // the data sidecar already carries follower counts and viewer status, the 83 121 // only extra cost is one miniDoc per did 84 122 const resolvePeople = async ( 85 123 dids: string[], 86 - stats: Stats, 124 + data: Sidecar, 125 + docSource: LinkSource, 87 126 cache: IdentityCache, 88 127 viewerDid?: string 89 128 ): Promise<PersonData[]> => { 90 129 const unique = [...new Set(dids)]; 91 130 92 - const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 131 + const docs = await Promise.all(unique.map((did) => docOrResolve(data, docSource, cache, did))); 93 132 94 133 const byDid = new Map<string, PersonData>(); 95 134 unique.forEach((did, index) => { 96 135 const doc = docs[index]; 97 - const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); 98 - const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); 136 + const followers = countOf(data, did, "sh.tangled.graph.follow:subject"); 137 + const following = countOf(data, did, "sh.tangled.graph.follow:.repo"); 99 138 const isSelf = viewerDid === did; 100 - const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); 139 + const viewerUri = viewerUriOf(data, did, FOLLOW_VIEWER.source); 101 140 const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; 102 141 byDid.set( 103 142 did, ··· 119 158 const resolveVouches = async ( 120 159 items: ListItem[], 121 160 direction: "incoming" | "outgoing", 161 + data: Sidecar | null, 122 162 cache: IdentityCache 123 163 ): Promise<VouchData[]> => { 124 164 return Promise.all( 125 165 items.map(async (item): Promise<VouchData> => { 126 166 const value = item.value as VouchRecord; 127 167 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 128 - const doc = await cache.resolve(otherDid).catch(() => null); 168 + // outgoing vouches name the subject in the rkey, which the sidecar 169 + // can't see. those still resolve client-side 170 + const doc = 171 + (direction === "incoming" && data 172 + ? miniDocOf(data, otherDid, VOUCHER_DOCS.source) 173 + : undefined) ?? (await cache.resolve(otherDid).catch(() => null)); 129 174 return { 130 175 uri: item.uri, 131 176 did: otherDid, ··· 141 186 142 187 const resolveStars = async ( 143 188 ctx: BobbinContext, 189 + starData: Sidecar, 144 190 items: ListItem[], 145 191 cache: IdentityCache, 146 192 viewerDid?: string ··· 157 203 ? await enrich<{ items: ListItem[] }>(ctx, { 158 204 xrpc: "sh.tangled.repo.getReposByRepoDids", 159 205 params: { dids: repoDids }, 160 - enrich: starDescriptors(viewerDid), 206 + enrich: [...starDescriptors(viewerDid), REPO_OWNER_DOCS], 161 207 ...(viewerDid ? { viewer: viewerDid } : {}) 162 208 }) 163 - : { output: { items: [] }, stats: {} as Stats }; 209 + : { output: { items: [] }, data: {} as Sidecar }; 164 210 const reposByDid = new Map( 165 211 enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) 166 212 ); ··· 172 218 const repo = reposByDid.get(subject.did); 173 219 if (!repo) return null; 174 220 const ownerDid = didFromUri(repo.uri); 175 - const owner = await cache.resolve(ownerDid).catch(() => null); 221 + const owner = await docOrResolve(enriched.data, REPO_OWNER_DOCS.source, cache, ownerDid); 176 222 return { 177 223 kind: "repo", 178 224 uri: item.uri, 179 225 createdAt: value.createdAt, 180 - repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) 226 + repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.data) 181 227 }; 182 228 } 183 229 if (subject && "uri" in subject && subject.uri) { 184 230 const ownerDid = didFromUri(subject.uri); 185 - const owner = await cache.resolve(ownerDid).catch(() => null); 231 + const owner = await docOrResolve(starData, STAR_SUBJECT_DOCS.source, cache, ownerDid); 186 232 return { 187 233 kind: "string", 188 234 uri: item.uri, ··· 219 265 ...(viewerDid ? { viewer: viewerDid } : {}) 220 266 }); 221 267 return { 222 - items: enriched.output.items.map((item) => resolveRepoCard(item, handle, enriched.stats)), 268 + items: enriched.output.items.map((item) => resolveRepoCard(item, handle, enriched.data)), 223 269 cursor: enriched.output.cursor 224 270 }; 225 271 } ··· 230 276 ...(viewerDid ? { viewer: viewerDid } : {}) 231 277 }); 232 278 return { 233 - items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)), 279 + items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.data)), 234 280 cursor: enriched.output.cursor ?? undefined 235 281 }; 236 282 }; ··· 266 312 ctx: BobbinContext, 267 313 { did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions 268 314 ): Promise<ListPage<StarData>> => { 269 - const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { 270 - subject: did as Did, 271 - limit, 272 - cursor 315 + const page = await enrich<RecordPage<ShTangledFeedStar.Main>>(ctx, { 316 + xrpc: "sh.tangled.feed.listStarsBy", 317 + params: { subject: did, limit, cursor }, 318 + enrich: [STAR_SUBJECT_DOCS] 273 319 }); 274 320 return { 275 - items: await resolveStars(ctx, page.items, cache ?? new IdentityCache(ctx), viewerDid), 276 - cursor: page.cursor 321 + items: await resolveStars( 322 + ctx, 323 + page.data, 324 + page.output.items, 325 + cache ?? new IdentityCache(ctx), 326 + viewerDid 327 + ), 328 + cursor: page.output.cursor 277 329 }; 278 330 }; 279 331 ··· 290 342 ctx: BobbinContext, 291 343 { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions 292 344 ): Promise<ListPage<PersonData>> => { 345 + const docs = direction === "followers" ? FOLLOWER_DOCS : FOLLOWING_DOCS; 293 346 const enriched = await enrich<RecordPage<ShTangledGraphFollow.Main>>(ctx, { 294 347 xrpc: 295 348 direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy", 296 349 params: { subject: did, limit, cursor }, 297 - enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 350 + enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER, docs] : [...FOLLOW_STATS, docs], 298 351 ...(viewerDid ? { viewer: viewerDid } : {}) 299 352 }); 300 353 const dids = ··· 302 355 ? enriched.output.items.map((item) => didFromUri(item.uri)) 303 356 : enriched.output.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 304 357 return { 305 - items: await resolvePeople(dids, enriched.stats, cache ?? new IdentityCache(ctx), viewerDid), 358 + items: await resolvePeople( 359 + dids, 360 + enriched.data, 361 + docs.source, 362 + cache ?? new IdentityCache(ctx), 363 + viewerDid 364 + ), 306 365 cursor: enriched.output.cursor 307 366 }; 308 367 }; ··· 330 389 { did, cursors = {}, cache, limit = PROFILE_PAGE_LIMIT }: VouchesPageOptions 331 390 ): Promise<VouchesPage> => { 332 391 const identity = cache ?? new IdentityCache(ctx); 333 - const [incomingPage, outgoingPage] = await Promise.all([ 392 + const [incoming, outgoingPage] = await Promise.all([ 334 393 cursors.incoming === null 335 - ? { items: [], cursor: undefined } 336 - : fetchPage(ctx, "sh.tangled.graph.listVouches", { 337 - subject: did as Did, 338 - limit, 339 - cursor: cursors.incoming 394 + ? { output: { items: [], cursor: undefined }, data: {} as Sidecar } 395 + : enrich<RecordPage<VouchRecord>>(ctx, { 396 + xrpc: "sh.tangled.graph.listVouches", 397 + params: { subject: did, limit, cursor: cursors.incoming }, 398 + enrich: [VOUCHER_DOCS] 340 399 }), 341 400 cursors.outgoing === null 342 401 ? { items: [], cursor: undefined } ··· 346 405 cursor: cursors.outgoing 347 406 }) 348 407 ]); 349 - const [incoming, outgoing] = await Promise.all([ 350 - resolveVouches(incomingPage.items, "incoming", identity), 351 - resolveVouches(outgoingPage.items, "outgoing", identity) 408 + const [incomingVouches, outgoingVouches] = await Promise.all([ 409 + resolveVouches(incoming.output.items, "incoming", incoming.data, identity), 410 + resolveVouches(outgoingPage.items, "outgoing", null, identity) 352 411 ]); 353 412 return { 354 - items: [...incoming, ...outgoing].sort( 413 + items: [...incomingVouches, ...outgoingVouches].sort( 355 414 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 356 415 ), 357 416 cursors: { 358 - incoming: incomingPage.cursor ?? null, 417 + incoming: incoming.output.cursor ?? null, 359 418 outgoing: outgoingPage.cursor ?? null 360 419 } 361 420 }; ··· 377 436 const dids = keys.filter((key) => key.startsWith("did:")); 378 437 const uris = keys.filter((key) => key.startsWith("at://")); 379 438 const descriptors = starDescriptors(viewerDid); 380 - const empty = { output: { items: [] as ListItem[] }, stats: {} as Stats }; 439 + const empty = { output: { items: [] as ListItem[] }, data: {} as Sidecar }; 381 440 const [byDid, byUri] = await Promise.all([ 382 441 dids.length > 0 383 442 ? enrich<{ items: ListItem[] }>(ctx, { ··· 399 458 const cards = new Map<string, RepoCardData>(); 400 459 for (const item of byDid.output.items) { 401 460 const repoDid = (item.value as RepoRecord).repoDid; 402 - if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.stats)); 461 + if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.data)); 403 462 } 404 463 for (const item of byUri.output.items) { 405 - cards.set(item.uri, resolveRepoCard(item, handle, byUri.stats)); 464 + cards.set(item.uri, resolveRepoCard(item, handle, byUri.data)); 406 465 } 407 466 return keys 408 467 .map((key) => cards.get(key))
+12 -5
web/src/routes/[handle]/[repo]/issues/+page.ts
··· 1 1 import { createBobbinClient } from "$lib/api/client"; 2 2 import { count } from "$lib/api/count"; 3 + import { enrich, miniDocOf, TYPE_MINIDOC } from "$lib/api/enrich"; 3 4 import { IdentityCache } from "$lib/api/identity"; 4 - import { listIssues } from "$lib/api/records"; 5 + import type { IssueListPage } from "$lib/api/records"; 5 6 import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 6 7 import type { IssueSummary } from "$lib/components/repo/types"; 7 8 import type { PageLoad } from "./$types"; ··· 24 25 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 25 26 26 27 const [page, closed] = await Promise.all([ 27 - listIssues(ctx, repoDid, { state }), 28 - // the layout only knows the open count; the closed tab needs its own 28 + enrich<IssueListPage>(ctx, { 29 + xrpc: "sh.tangled.repo.listIssues", 30 + params: { subject: repoDid, state }, 31 + enrich: [{ source: "sh.tangled.repo.issue:.repo", type: TYPE_MINIDOC }] 32 + }), 33 + // the layout only knows the open count, the closed tab needs its own 29 34 count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }).catch(() => null) 30 35 ]); 31 36 32 37 const identity = new IdentityCache(ctx); 33 38 const issues: IssueSummary[] = await Promise.all( 34 - page.items.map(async (item): Promise<IssueSummary> => { 39 + page.output.items.map(async (item): Promise<IssueSummary> => { 35 40 const authorDid = didFromUri(item.uri); 36 - const author = await identity.resolve(authorDid).catch(() => null); 41 + const author = 42 + miniDocOf(page.data, authorDid, "sh.tangled.repo.issue:.repo") ?? 43 + (await identity.resolve(authorDid).catch(() => null)); 37 44 return { 38 45 uri: item.uri, 39 46 rkey: rkeyFromUri(item.uri),
+47 -15
web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts
··· 1 1 import { error } from "@sveltejs/kit"; 2 2 import { createBobbinClient } from "$lib/api/client"; 3 - import { IdentityCache } from "$lib/api/identity"; 3 + import { enrich, miniDocOf, TYPE_MINIDOC, type LinkSource, type Sidecar } from "$lib/api/enrich"; 4 + import { IdentityCache, type MiniDoc } from "$lib/api/identity"; 4 5 import { 5 - getIssue, 6 - listComments, 7 6 listIssueStates, 8 - listReactions, 7 + type CommentListPage, 8 + type IssueRecord, 9 + type ReactionListPage, 9 10 type RecordView, 10 11 type ReactionRecord 11 12 } from "$lib/api/records"; ··· 23 24 24 25 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 25 26 26 - const record = await getIssue(ctx, uri).catch(() => null); 27 - if (!record) error(404, "Issue not found"); 27 + const issuePage = await enrich<RecordView<IssueRecord>>(ctx, { 28 + xrpc: "sh.tangled.repo.getIssue", 29 + params: { issue: uri }, 30 + enrich: [{ source: "sh.tangled.repo.issue:.repo", type: TYPE_MINIDOC }] 31 + }).catch(() => null); 32 + if (!issuePage) error(404, "Issue not found"); 28 33 34 + const record = issuePage.output; 29 35 const authorDid = didFromUri(record.uri); 30 36 const identities = new IdentityCache(ctx); 37 + const docOrResolve = ( 38 + data: Sidecar, 39 + source: LinkSource, 40 + did: string 41 + ): Promise<MiniDoc | null> => { 42 + const doc = miniDocOf(data, did, source); 43 + return doc ? Promise.resolve(doc) : identities.resolve(did).catch(() => null); 44 + }; 31 45 const markupOpts = { 32 46 repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 33 47 ref: parent.repo.defaultBranch, ··· 35 49 }; 36 50 37 51 const [author, states, comments] = await Promise.all([ 38 - identities.resolve(authorDid).catch(() => null), 52 + docOrResolve(issuePage.data, "sh.tangled.repo.issue:.repo", authorDid), 39 53 listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null), 40 - listComments(ctx, record.uri, { order: "asc", limit: 100 }).catch(() => null) 54 + enrich<CommentListPage>(ctx, { 55 + xrpc: "sh.tangled.feed.listComments", 56 + params: { subject: record.uri, order: "asc", limit: 100 }, 57 + enrich: [{ source: "sh.tangled.feed.comment:.repo", type: TYPE_MINIDOC }] 58 + }).catch(() => null) 41 59 ]); 42 60 43 61 const latest = states?.items[0]?.value.state; ··· 45 63 const body = record.value.body ?? ""; 46 64 const bodyHtml = body ? await renderMarkup(body, markupOpts) : null; 47 65 const viewerDid = parent.auth?.did; 48 - const commentItems = comments?.items ?? []; 66 + const commentItems = comments?.output.items ?? []; 49 67 50 68 // reactions: one listReactions per subject (the issue itself + each comment). 51 69 // bobbin has no batch-by-subject query and enrich can't break counts down by 52 - // emoji, so we fan out and group client-side. empty subjects come back empty. 70 + // emoji, so fan out and group client-side. reactor docs ride along in the 71 + // same enrich calls 53 72 const subjects = [record.uri, ...commentItems.map((item) => item.uri)]; 54 73 const reactionPages = await Promise.all( 55 74 subjects.map((subject) => 56 - listReactions(ctx, subject, { order: "asc", limit: 100 }).catch(() => null) 75 + enrich<ReactionListPage>(ctx, { 76 + xrpc: "sh.tangled.feed.listReactions", 77 + params: { subject, order: "asc", limit: 100 }, 78 + enrich: [{ source: "sh.tangled.feed.reaction:.repo", type: TYPE_MINIDOC }] 79 + }).catch(() => null) 57 80 ) 58 81 ); 59 82 const reactionsBySubject = new Map<string, RecordView<ReactionRecord>[]>(); 60 - subjects.forEach((subject, i) => reactionsBySubject.set(subject, reactionPages[i]?.items ?? [])); 83 + subjects.forEach((subject, i) => 84 + reactionsBySubject.set(subject, reactionPages[i]?.output.items ?? []) 85 + ); 61 86 62 - // resolve every reactor's handle once for the whole page (IdentityCache dedups) 63 87 const reactorDids = new Set<string>(); 64 88 for (const items of reactionsBySubject.values()) { 65 89 for (const item of items) reactorDids.add(didFromUri(item.uri)); 66 90 } 91 + // a reactor's doc can land in any subject's sidecar 92 + const reactorDoc = (did: string): MiniDoc | undefined => { 93 + for (const page of reactionPages) { 94 + const doc = page && miniDocOf(page.data, did, "sh.tangled.feed.reaction:.repo"); 95 + if (doc) return doc; 96 + } 97 + return undefined; 98 + }; 67 99 const reactorHandles = new Map<string, string>(); 68 100 await Promise.all( 69 101 [...reactorDids].map(async (did) => { 70 - const doc = await identities.resolve(did).catch(() => null); 102 + const doc = reactorDoc(did) ?? (await identities.resolve(did).catch(() => null)); 71 103 reactorHandles.set(did, doc?.handle ?? did); 72 104 }) 73 105 ); ··· 83 115 const commentDid = didFromUri(item.uri); 84 116 const commentBody = item.value.body?.text ?? ""; 85 117 const [doc, commentBodyHtml] = await Promise.all([ 86 - identities.resolve(commentDid).catch(() => null), 118 + docOrResolve(comments?.data ?? {}, "sh.tangled.feed.comment:.repo", commentDid), 87 119 commentBody ? renderMarkup(commentBody, markupOpts) : Promise.resolve(null) 88 120 ]); 89 121 return {