diff --git a/Cargo.lock b/Cargo.lock index 68e7b44c..f2d050c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8420,6 +8420,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" + [[package]] name = "trusted-proxies" version = "0.0.1" @@ -8428,12 +8434,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "triomphe" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" - [[package]] name = "try-lock" version = "0.2.5" diff --git a/bobbin/crates/types/Cargo.toml b/bobbin/crates/types/Cargo.toml index ff2a23c0..c2b536fa 100644 --- a/bobbin/crates/types/Cargo.toml +++ b/bobbin/crates/types/Cargo.toml @@ -25,6 +25,6 @@ walkdir = { workspace = true } [features] default = ["sh_tangled"] com_atproto = [] +com_bad_example = [] sh_tangled = ["com_atproto"] streaming = ["jacquard-common/websocket"] - diff --git a/bobbin/crates/xrpc/src/enrich.rs b/bobbin/crates/xrpc/src/enrich.rs index 1a838113..f7512ac8 100644 --- a/bobbin/crates/xrpc/src/enrich.rs +++ b/bobbin/crates/xrpc/src/enrich.rs @@ -8,57 +8,162 @@ use axum::{ response::Response, }; use bobbin_types::ids::{EdgeKey, SubjectRef, nsid_static}; +use futures::StreamExt; use jacquard_common::DefaultStr; +use jacquard_common::IntoStatic; use jacquard_common::types::did::Did; use jacquard_common::types::ident::AtIdentifier; +use jacquard_common::types::nsid::Nsid; use jacquard_common::types::string::AtUri; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use tower::ServiceExt; -use crate::recordpath::{parse_record_path, walk_path}; +use crate::recordpath::{RecordPath, walk_path}; use crate::{AppState, SubjectShape, XrpcError, mirror_kind, subject_shape}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Aggregation { - Count, - DistinctAuthors, - Viewer, +pub const TYPE_COUNT: &str = "sh.tangled.query.enrichResponse#count"; +pub const TYPE_DISTINCT_AUTHORS: &str = "sh.tangled.query.enrichResponse#distinctAuthors"; +pub const TYPE_VIEWER: &str = "sh.tangled.query.enrichResponse#viewer"; +/// vendored from upstream com.bad-example.identity.resolveMiniDoc's output +pub const TYPE_MINIDOC: &str = "com.bad-example.identity.miniDoc"; + +const KNOWN_TYPES: [&str; 4] = [TYPE_COUNT, TYPE_DISTINCT_AUTHORS, TYPE_VIEWER, TYPE_MINIDOC]; + +/// a payload type nsid, with an optional #fragment for lexicon defs. the raw +/// string is kept because it echoes into the data map as the payload key +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PayloadType { + raw: String, + nsid: Nsid, + #[allow(dead_code)] + fragment: Option, } -impl Aggregation { - fn key(self) -> &'static str { - match self { - Aggregation::Count => "count", - Aggregation::DistinctAuthors => "distinctAuthors", - Aggregation::Viewer => "viewer", - } +impl PayloadType { + pub fn parse(raw: &str) -> Result { + let (nsid, fragment) = match raw.split_once('#') { + Some((nsid, fragment)) => { + let valid = !fragment.is_empty() + && fragment.starts_with(|c: char| c.is_ascii_alphabetic()) + && fragment.chars().all(|c| c.is_ascii_alphanumeric()); + if !valid { + return Err(format!("invalid fragment #{fragment}")); + } + (nsid, Some(fragment.to_owned())) + } + None => (raw, None), + }; + let nsid = Nsid::new_owned(nsid).map_err(|e| format!("invalid nsid: {e}"))?; + Ok(Self { + raw: raw.to_owned(), + nsid, + fragment, + }) + } + + fn as_str(&self) -> &str { + &self.raw } } -#[derive(Debug, Deserialize)] -pub struct LinkDescriptor { - /// constellation link source - source: String, - #[serde(rename = "type")] - aggregation: Aggregation, +impl<'de> Deserialize<'de> for PayloadType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for PayloadType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.raw) + } +} + +/// the paths the edge index can back: a record's reference field, or its +/// authoring repo +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SourcePath { + Subject, + Repo, } -impl LinkDescriptor { - fn parts(&self) -> Result<(&str, &str, Aggregation), XrpcError> { - match self.source.split_once(':') { +/// a "collection:path" link source. raw echoes into the data map verbatim +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LinkSource { + raw: String, + collection: Nsid, + path: SourcePath, +} + +impl LinkSource { + pub fn parse(raw: &str) -> Result { + let (collection, path) = match raw.split_once(':') { Some((collection, path)) if !collection.is_empty() && !path.is_empty() => { - Ok((collection, path, self.aggregation)) + (collection, path) } - _ => Err(XrpcError::InvalidParams(format!( - "enrich {:?}: expected \"collection:path\"", - self.source - ))), - } + _ => return Err("expected \"collection:path\"".to_owned()), + }; + let collection = + Nsid::new_owned(collection).map_err(|e| format!("invalid collection: {e}"))?; + let path = match path { + "subject" => SourcePath::Subject, + ".repo" => SourcePath::Repo, + _ if path.starts_with('.') => { + return Err("envelope field not index-backed; try .repo".to_owned()); + } + _ => { + return Err( + "path not index-backed; only `subject` and `.repo` are supported".to_owned(), + ); + } + }; + Ok(Self { + raw: raw.to_owned(), + collection, + path, + }) + } + + fn as_str(&self) -> &str { + &self.raw } } +impl<'de> Deserialize<'de> for LinkSource { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for LinkSource { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.raw) + } +} + +#[derive(Debug, Deserialize)] +pub struct LinkDescriptor { + /// constellation link source + source: LinkSource, + /// nsid of the sidecar payload to produce for this source + #[serde(rename = "type")] + ty: PayloadType, +} + #[derive(Debug, Deserialize)] pub struct EnrichInput { xrpc: String, @@ -76,11 +181,14 @@ pub async fn enrich( Json(input): Json, ) -> Result, XrpcError> { for descriptor in &input.enrich { - validate_descriptor(descriptor)?; - if descriptor.aggregation == Aggregation::Viewer && input.viewer.is_none() { + if !KNOWN_TYPES.contains(&descriptor.ty.as_str()) { + return Err(descriptor_error(&descriptor.source, "unknown enrich type")); + } + validate_source(&descriptor.source, &descriptor.ty)?; + if descriptor.ty.as_str() == TYPE_VIEWER && input.viewer.is_none() { return Err(descriptor_error( - descriptor, - "viewer aggregation requires a viewer param", + &descriptor.source, + "viewer payloads require a viewer param", )); } } @@ -92,105 +200,178 @@ pub async fn enrich( match &input.sources { Some(sources) => { for (i, path) in sources.iter().enumerate() { - let segs = parse_record_path(path) + let path = RecordPath::parse(path) .map_err(|e| XrpcError::InvalidParams(format!("sources[{i}] {path:?}: {e}")))?; - for node in walk_path(&segs, [&inner]) { + for node in walk_path(&path, [&inner]) { collect_ref(node, &mut refs, &mut seen); } } } None => discover_refs(&inner, &mut refs, &mut seen), } + // the authority of every at-uri is a reference too: it's embedded in the + // response, and it's the only way record authors get payloads + let authorities: Vec = refs + .iter() + .filter_map(|reference| repo_did(reference).map(SubjectRef::Did)) + .collect(); + for authority in authorities { + if seen.insert(authority.clone()) { + refs.push(authority); + } + } - let mut stats = Map::new(); - for reference in &refs { - let mut per_ref = Map::new(); - for descriptor in &input.enrich { - let (.., aggregation) = descriptor.parts()?; - let Some(kind) = resolve_descriptor(descriptor, reference)? else { + let mut data = Map::new(); + let mut minidoc_targets: Vec<(Did, LinkSource)> = Vec::new(); + for descriptor in &input.enrich { + for reference in &refs { + let Some(subject) = applicable_subject(descriptor, reference)? else { continue; }; - let key = EdgeKey::new(kind, reference.clone()); - let result = match aggregation { - Aggregation::Count => Value::from(state.edges.count(&key)), - Aggregation::DistinctAuthors => { - Value::from(state.edges.count_distinct_authors(&key)) + match descriptor.ty.as_str() { + TYPE_COUNT | TYPE_DISTINCT_AUTHORS | TYPE_VIEWER => { + let kind = edge_kind(&descriptor.source)?; + let key = EdgeKey::new(kind, subject.clone()); + let result = match descriptor.ty.as_str() { + TYPE_COUNT => Value::from(state.edges.count(&key)), + TYPE_DISTINCT_AUTHORS => { + Value::from(state.edges.count_distinct_authors(&key)) + } + _ => { + // viewer presence is validated up front + let viewer = input.viewer.as_ref().expect("viewer param present"); + match state.edges.viewer_source(&key, viewer.as_str()) { + Some(uri) => Value::String(uri.to_string()), + None => Value::Null, + } + } + }; + put( + &mut data, + subject.as_str(), + descriptor.source.as_str(), + descriptor.ty.as_str(), + result, + ); } - Aggregation::Viewer => { - // viewer presence is validated up front - let viewer = input.viewer.as_ref().expect("viewer param present"); - match state.edges.viewer_source(&key, viewer.as_str()) { - Some(uri) => Value::String(uri.to_string()), - None => Value::Null, + TYPE_MINIDOC => { + if let Some(target) = repo_did(&subject) { + minidoc_targets.push((target, descriptor.source.clone())); } } - }; - let entry = per_ref - .entry(descriptor.source.clone()) - .or_insert_with(|| Value::Object(Map::new())); - if let Value::Object(counts) = entry { - counts.insert(aggregation.key().to_owned(), result); + _ => unreachable!("validated up front"), } } - if !per_ref.is_empty() { - stats.insert(reference.as_str().to_owned(), Value::Object(per_ref)); - } } - Ok(Json(json!({ "output": inner, "stats": stats }))) + let docs = resolve_minidocs(&state, minidoc_targets).await; + for (target, source, doc) in docs { + put( + &mut data, + target.as_str(), + source.as_str(), + TYPE_MINIDOC, + doc, + ); + } + + Ok(Json(json!({ "output": inner, "data": data }))) +} + +fn put(data: &mut Map, reference: &str, source: &str, ty: &str, payload: Value) { + let per_ref = data + .entry(reference.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + let Value::Object(per_ref) = per_ref else { + return; + }; + let per_source = per_ref + .entry(source.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(per_source) = per_source { + per_source.insert(ty.to_owned(), payload); + } +} + +/// we drop failures, the client falls back to resolveMiniDoc for misses +async fn resolve_minidocs( + state: &AppState, + targets: Vec<(Did, LinkSource)>, +) -> Vec<(Did, LinkSource, Value)> { + futures::stream::iter(targets) + .map(|(did, source)| async move { + let doc = state + .slingshot + .resolve_mini_doc(&AtIdentifier::Did(did.clone())) + .await + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + (did, source, doc) + }) + .buffered(crate::FETCH_CONCURRENCY) + .filter_map(|(did, source, doc)| async move { doc.map(|doc| (did, source, doc)) }) + .collect() + .await } -fn descriptor_error(descriptor: &LinkDescriptor, msg: &str) -> XrpcError { - XrpcError::InvalidParams(format!("enrich {}: {msg}", descriptor.source)) +fn descriptor_error(source: &LinkSource, msg: &str) -> XrpcError { + XrpcError::InvalidParams(format!("enrich {}: {msg}", source.as_str())) } -fn validate_descriptor(descriptor: &LinkDescriptor) -> Result<(), XrpcError> { - let (collection, path, _) = descriptor.parts()?; - match path { - "subject" => subject_shape(collection) +fn validate_source(source: &LinkSource, ty: &PayloadType) -> Result<(), XrpcError> { + match source.path { + SourcePath::Subject => subject_shape(source.collection.as_ref()) .map(|_| ()) - .ok_or_else(|| descriptor_error(descriptor, "unknown collection")), - ".repo" => mirror_kind(collection) + .ok_or_else(|| descriptor_error(source, "unknown collection")), + // minidocs read the uri's authority straight out of the response, only + // edge payloads need the author index + SourcePath::Repo if ty.as_str() == TYPE_MINIDOC => Ok(()), + SourcePath::Repo => mirror_kind(source.collection.as_ref()) .map(|_| ()) - .ok_or_else(|| descriptor_error(descriptor, "collection has no author index")), - _ if path.starts_with('.') => Err(descriptor_error( - descriptor, - "envelope field not index-backed; try .repo", - )), - _ => Err(descriptor_error( - descriptor, - "path not index-backed; only `subject` and `.repo` are supported", - )), + .ok_or_else(|| descriptor_error(source, "collection has no author index")), } } -/// the edge kind to look up for one reference, or None if the descriptor's shape -/// doesn't apply to this ref kind, eg. a did-only descriptor asked about an at-uri -fn resolve_descriptor( +fn repo_did(reference: &SubjectRef) -> Option> { + match reference { + SubjectRef::Did(did) => Some(did.clone()), + SubjectRef::Uri(uri) => match uri.authority() { + AtIdentifier::Did(did) => Some(did.clone().into_static()), + AtIdentifier::Handle(_) => None, + }, + SubjectRef::Global => None, + } +} + +/// the subject an edge lookup or payload should be keyed by, or None if the +/// descriptor's shape doesn't apply to this ref, eg. a did-only source asked +/// about an at-uri. `.repo` sources normalize to the repo did +fn applicable_subject( descriptor: &LinkDescriptor, reference: &SubjectRef, -) -> Result>, XrpcError> { - let (collection, path, _) = descriptor.parts()?; - match path { - "subject" => { - let Some((nsid, shape)) = subject_shape(collection) else { - return Err(descriptor_error(descriptor, "unknown collection")); - }; - Ok(shape_accepts(shape, reference).then(|| nsid_static(nsid))) +) -> Result, XrpcError> { + match descriptor.source.path { + SourcePath::Subject => { + let (_, shape) = subject_shape(descriptor.source.collection.as_ref()) + .ok_or_else(|| descriptor_error(&descriptor.source, "unknown collection"))?; + Ok(shape_accepts(shape, reference).then(|| reference.clone())) } - ".repo" => { - let Some(kind) = mirror_kind(collection) else { - return Err(descriptor_error( - descriptor, - "collection has no author index", - )); - }; - Ok(matches!(reference, SubjectRef::Did(_)).then(|| nsid_static(kind))) - } - _ => unreachable!("validated up front"), + SourcePath::Repo => Ok(repo_did(reference).map(SubjectRef::Did)), } } +/// the edge kind behind a source for stats payloads +fn edge_kind(source: &LinkSource) -> Result, XrpcError> { + let kind = match source.path { + SourcePath::Subject => subject_shape(source.collection.as_ref()) + .map(|(nsid, _)| nsid) + .ok_or_else(|| descriptor_error(source, "unknown collection"))?, + SourcePath::Repo => mirror_kind(source.collection.as_ref()) + .ok_or_else(|| descriptor_error(source, "collection has no author index"))?, + }; + Ok(nsid_static(kind)) +} + /// mismatch means skip, not reject fn shape_accepts(shape: SubjectShape, reference: &SubjectRef) -> bool { match (shape, reference) { @@ -362,4 +543,40 @@ mod tests { collect_ref(&json!("did:plc:abc"), &mut refs, &mut seen); assert_eq!(refs.len(), 3); } + + #[test] + fn repo_did_normalizes_uri_authorities() { + let uri = SubjectRef::Uri(AtUri::new_owned("at://did:plc:abc/sh.tangled.repo/x").unwrap()); + let bare = SubjectRef::Did(Did::new_owned("did:plc:abc").unwrap()); + assert_eq!( + repo_did(&uri).as_ref().map(|d| d.as_str()), + Some("did:plc:abc") + ); + assert_eq!( + repo_did(&bare).as_ref().map(|d| d.as_str()), + Some("did:plc:abc") + ); + assert_eq!(repo_did(&SubjectRef::Global), None); + } + + #[test] + fn parses_link_sources_and_payload_types() { + let source = LinkSource::parse("sh.tangled.feed.star:subject").unwrap(); + assert_eq!(source.collection.as_ref(), "sh.tangled.feed.star"); + assert_eq!(source.path, SourcePath::Subject); + let source = LinkSource::parse("sh.tangled.graph.follow:.repo").unwrap(); + assert_eq!(source.path, SourcePath::Repo); + assert!(LinkSource::parse("sh.tangled.feed.star").is_err()); + assert!(LinkSource::parse("sh.tangled.feed.star:.rkey").is_err()); + assert!(LinkSource::parse("not an nsid:subject").is_err()); + + let ty = PayloadType::parse(TYPE_MINIDOC).unwrap(); + assert_eq!(ty.nsid.as_ref(), "com.bad-example.identity.miniDoc"); + assert_eq!(ty.fragment.as_deref(), None); + let ty = PayloadType::parse(TYPE_COUNT).unwrap(); + assert_eq!(ty.nsid.as_ref(), "sh.tangled.query.enrichResponse"); + assert_eq!(ty.fragment.as_deref(), Some("count")); + assert!(PayloadType::parse("sh.tangled.query.enrichResponse#").is_err()); + assert!(PayloadType::parse("nope#count").is_err()); + } } diff --git a/bobbin/crates/xrpc/src/recordpath.rs b/bobbin/crates/xrpc/src/recordpath.rs index 3b6305ec..d69cf71d 100644 --- a/bobbin/crates/xrpc/src/recordpath.rs +++ b/bobbin/crates/xrpc/src/recordpath.rs @@ -18,6 +18,15 @@ pub(crate) struct Segment { modifier: Option, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RecordPath(Vec); + +impl RecordPath { + pub(crate) fn parse(path: &str) -> Result { + parse_record_path(path).map(Self) + } +} + pub(crate) fn parse_record_path(path: &str) -> Result, String> { let chars: Vec = path.chars().collect(); let mut segments = Vec::new(); @@ -116,11 +125,11 @@ pub(crate) fn type_tag(value: &Value) -> Option<&str> { } pub(crate) fn walk_path<'a>( - segments: &[Segment], + path: &RecordPath, roots: impl IntoIterator, ) -> Vec<&'a Value> { let mut nodes: Vec<&Value> = roots.into_iter().collect(); - for segment in segments { + for segment in &path.0 { let mut next: Vec<&Value> = nodes .into_iter() .filter_map(|node| node.get(&segment.field)) @@ -281,8 +290,8 @@ mod tests { ], "owner": "did:plc:z", }); - let segs = parse_ok("repos[].uri"); - let found = walk_path(&segs, [&doc]); + let path = RecordPath::parse("repos[].uri").unwrap(); + let found = walk_path(&path, [&doc]); assert_eq!(found.len(), 2); } @@ -295,7 +304,7 @@ mod tests { ] }); let segs = parse_ok("items[sh.tangled.repo].uri"); - let found = walk_path(&segs, [&doc]); + let found = walk_path(&RecordPath(segs), [&doc]); assert_eq!(found, vec![&json!("at://did:plc:a/sh.tangled.repo/x")]); } } diff --git a/bobbin/crates/xrpc/tests/enrich.rs b/bobbin/crates/xrpc/tests/enrich.rs index 57cf0770..b5b6985d 100644 --- a/bobbin/crates/xrpc/tests/enrich.rs +++ b/bobbin/crates/xrpc/tests/enrich.rs @@ -24,6 +24,11 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i"; +const COUNT: &str = "sh.tangled.query.enrichResponse#count"; +const DISTINCT_AUTHORS: &str = "sh.tangled.query.enrichResponse#distinctAuthors"; +const VIEWER: &str = "sh.tangled.query.enrichResponse#viewer"; +const MINIDOC: &str = "com.bad-example.identity.miniDoc"; + fn at(s: &str) -> AtUri { AtUri::new_owned(s).unwrap() } @@ -177,9 +182,9 @@ async fn zero_config_counts_stars_and_issues_for_repo_did() { "xrpc": "sh.tangled.repo.listRepos", "params": { "subject": owner.as_ref() }, "enrich": [ - { "source": "sh.tangled.feed.star:subject", "type": "count" }, - { "source": "sh.tangled.feed.star:subject", "type": "distinctAuthors" }, - { "source": "sh.tangled.repo.issue:subject", "type": "count" } + { "source": "sh.tangled.feed.star:subject", "type": COUNT }, + { "source": "sh.tangled.feed.star:subject", "type": DISTINCT_AUTHORS }, + { "source": "sh.tangled.repo.issue:subject", "type": COUNT } ] }))) .await @@ -189,17 +194,17 @@ async fn zero_config_counts_stars_and_issues_for_repo_did() { assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!(body["output"]["items"].as_array().unwrap().len(), 1); - let stats = &body["stats"]; + let stats = &body["data"]; assert_eq!( - stats["did:plc:limpet"]["sh.tangled.feed.star:subject"]["count"], + stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT], json!(3) ); assert_eq!( - stats["did:plc:limpet"]["sh.tangled.feed.star:subject"]["distinctAuthors"], + stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][DISTINCT_AUTHORS], json!(2) ); assert_eq!( - stats["did:plc:limpet"]["sh.tangled.repo.issue:subject"]["count"], + stats["did:plc:limpet"]["sh.tangled.repo.issue:subject"][COUNT], json!(1) ); assert!(stats["at://did:plc:nel/sh.tangled.repo/reef"].is_null()); @@ -236,7 +241,7 @@ async fn follow_counts_cover_both_directions() { app.oneshot(enrich_request(json!({ "xrpc": "sh.tangled.graph.listFollows", "params": { "subject": owner.as_ref() }, - "enrich": [{ "source": "sh.tangled.graph.follow:subject", "type": "count" }, { "source": "sh.tangled.graph.follow:.repo", "type": "count" }] + "enrich": [{ "source": "sh.tangled.graph.follow:subject", "type": COUNT }, { "source": "sh.tangled.graph.follow:.repo", "type": COUNT }] }))) .await .unwrap(), @@ -244,19 +249,79 @@ async fn follow_counts_cover_both_directions() { .await; assert_eq!(status, StatusCode::OK, "{body}"); - let nel = &body["stats"]["did:plc:nel"]; + let nel = &body["data"]["did:plc:nel"]; assert_eq!( - nel["sh.tangled.graph.follow:subject"]["count"], + nel["sh.tangled.graph.follow:subject"][COUNT], json!(2), "{body}" ); assert_eq!( - nel["sh.tangled.graph.follow:.repo"]["count"], + nel["sh.tangled.graph.follow:.repo"][COUNT], json!(1), "{body}" ); } +// at-uri authorities join the ref set, so record authors get stats keyed by +// their bare did without appearing as a value anywhere in the response +#[tokio::test] +async fn authorities_of_record_uris_become_refs() { + let h = Harness::new().await; + let owner = did("did:plc:nel"); + for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() { + h.add_edge( + "sh.tangled.graph.follow", + SubjectRef::Did(owner.clone()), + &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")), + ); + h.mount( + &did(fan), + "sh.tangled.graph.follow", + &format!("f{i}"), + follow_body(&owner), + ) + .await; + } + // each fan also follows one other person + for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() { + h.add_edge( + "sh.tangled.graph.follow.by", + SubjectRef::Did(did(fan)), + &at(&format!("at://{fan}/sh.tangled.graph.follow/g{i}")), + ); + } + + let app = router(h.state.clone()); + let (status, body) = json_response( + app.oneshot(enrich_request(json!({ + "xrpc": "sh.tangled.graph.listFollows", + "params": { "subject": owner.as_ref() }, + "enrich": [ + { "source": "sh.tangled.graph.follow:subject", "type": COUNT }, + { "source": "sh.tangled.graph.follow:.repo", "type": COUNT } + ] + }))) + .await + .unwrap(), + ) + .await; + + assert_eq!(status, StatusCode::OK, "{body}"); + for fan in ["did:plc:a", "did:plc:b"] { + let entry = &body["data"][fan]; + assert_eq!( + entry["sh.tangled.graph.follow:subject"][COUNT], + json!(0), + "{body}" + ); + assert_eq!( + entry["sh.tangled.graph.follow:.repo"][COUNT], + json!(1), + "{body}" + ); + } +} + #[tokio::test] async fn sources_scope_which_refs_get_enriched() { let h = Harness::new().await; @@ -269,7 +334,7 @@ async fn sources_scope_which_refs_get_enriched() { app.oneshot(enrich_request(json!({ "xrpc": "sh.tangled.repo.listRepos", "params": { "subject": owner.as_ref() }, - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "count" }], + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], "sources": ["items[].value.repoDid"] }))) .await @@ -277,9 +342,9 @@ async fn sources_scope_which_refs_get_enriched() { ) .await; assert_eq!(status, StatusCode::OK, "{body}"); - let stats = &body["stats"]; + let stats = &body["data"]; assert_eq!( - stats["did:plc:limpet"]["sh.tangled.feed.star:subject"]["count"], + stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT], json!(3) ); assert_eq!(stats.as_object().unwrap().len(), 1, "{body}"); @@ -290,7 +355,7 @@ async fn sources_scope_which_refs_get_enriched() { app.oneshot(enrich_request(json!({ "xrpc": "sh.tangled.repo.listRepos", "params": { "subject": owner.as_ref() }, - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "count" }], + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], "sources": ["items[].value.nope"] }))) .await @@ -298,7 +363,7 @@ async fn sources_scope_which_refs_get_enriched() { ) .await; assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["stats"], json!({})); + assert_eq!(body["data"], json!({})); } #[tokio::test] @@ -309,7 +374,7 @@ async fn inner_record_miss_passes_through_as_404() { app.oneshot(enrich_request(json!({ "xrpc": "sh.tangled.repo.getRepo", "params": { "repo": "at://did:plc:nel/sh.tangled.repo/absent" }, - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "count" }] + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }] }))) .await .unwrap(), @@ -322,55 +387,62 @@ async fn inner_record_miss_passes_through_as_404() { #[tokio::test] async fn rejects_bad_requests() { let h = Harness::new().await; - let cases = [ + // semantic rejections: the descriptor parses, the handler refuses it + let handler_cases = [ json!({ "xrpc": "sh.tangled.nope.nope", "enrich": [] }), json!({ "xrpc": "sh.tangled.repo.countRepos", "params": { "subject": "did:plc:nel" }, - "enrich": [{ "source": "sh.tangled.nope:subject", "type": "count" }] - }), - json!({ - "xrpc": "sh.tangled.repo.countRepos", - "params": { "subject": "did:plc:nel" }, - "enrich": [{ "source": "sh.tangled.feed.star:subject.uri", "type": "count" }] + "enrich": [{ "source": "sh.tangled.nope:subject", "type": COUNT }] }), json!({ "xrpc": "sh.tangled.repo.countRepos", "params": { "subject": "did:plc:nel" }, - "enrich": [{ "source": "sh.tangled.feed.star:.rkey", "type": "count" }] + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "sh.tangled.query.enrichResponse#bogus" }] }), json!({ "xrpc": "sh.tangled.repo.countRepos", "params": { "subject": "did:plc:nel" }, - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "count" }], + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], "sources": ["items["] }), - // no defaults, a source without a colon is rejected - json!({ - "xrpc": "sh.tangled.repo.countRepos", - "params": { "subject": "did:plc:nel" }, - "enrich": [{ "source": "sh.tangled.feed.star", "type": "count" }] - }), ]; - for case in cases { + for case in handler_cases { let app = router(h.state.clone()); let (status, body) = json_response(app.oneshot(enrich_request(case)).await.unwrap()).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!(body["error"], json!("InvalidRequest"), "{body}"); } - // serde rejects a missing type before the handler sees it - // that returns plain-text 422, not our 400 json body - let app = router(h.state.clone()); - let response = app - .oneshot(enrich_request(json!({ + // structural rejections: serde refuses the descriptor before the handler + // sees it, which is plain-text 422 rather than our 400 json body + let serde_cases = [ + json!({ "xrpc": "sh.tangled.repo.countRepos", "params": { "subject": "did:plc:nel" }, "enrich": [{ "source": "sh.tangled.feed.star:subject" }] - }))) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + }), + json!({ + "xrpc": "sh.tangled.repo.countRepos", + "params": { "subject": "did:plc:nel" }, + "enrich": [{ "source": "sh.tangled.feed.star", "type": COUNT }] + }), + json!({ + "xrpc": "sh.tangled.repo.countRepos", + "params": { "subject": "did:plc:nel" }, + "enrich": [{ "source": "sh.tangled.feed.star:.rkey", "type": COUNT }] + }), + json!({ + "xrpc": "sh.tangled.repo.countRepos", + "params": { "subject": "did:plc:nel" }, + "enrich": [{ "source": "sh.tangled.feed.star:subject.uri", "type": COUNT }] + }), + ]; + for case in serde_cases { + let app = router(h.state.clone()); + let response = app.oneshot(enrich_request(case)).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + } } #[tokio::test] @@ -395,7 +467,7 @@ async fn viewer_aggregation_uses_explicit_viewer_param() { let no_viewer = json!({ "xrpc": "sh.tangled.repo.listRepos", "params": { "subject": owner.as_ref() }, - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "viewer" }] + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }] }); let (status, _) = json_response( app.clone() @@ -410,7 +482,7 @@ async fn viewer_aggregation_uses_explicit_viewer_param() { let starred_viewer = json!({ "xrpc": "sh.tangled.repo.listRepos", "params": { "subject": owner.as_ref() }, - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "viewer" }], + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }], "viewer": "did:plc:nel" }); let (status, resp) = json_response( @@ -421,9 +493,9 @@ async fn viewer_aggregation_uses_explicit_viewer_param() { ) .await; assert_eq!(status, StatusCode::OK); - let stats = &resp["stats"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; + let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; assert_eq!( - stats["viewer"], + stats[VIEWER], json!("at://did:plc:nel/sh.tangled.feed.star/r99") ); @@ -431,12 +503,119 @@ async fn viewer_aggregation_uses_explicit_viewer_param() { let other_viewer = json!({ "xrpc": "sh.tangled.repo.listRepos", "params": { "subject": owner.as_ref() }, - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "viewer" }], + "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }], "viewer": "did:plc:someoneelse" }); let (status, resp) = json_response(app.oneshot(enrich_request(other_viewer)).await.unwrap()).await; assert_eq!(status, StatusCode::OK); - let stats = &resp["stats"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; - assert_eq!(stats["viewer"], Value::Null); + let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; + assert_eq!(stats[VIEWER], Value::Null); +} + +#[tokio::test] +async fn minidoc_payloads_resolve_record_authors() { + let h = Harness::new().await; + let owner = did("did:plc:nel"); + for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() { + h.add_edge( + "sh.tangled.graph.follow", + SubjectRef::Did(owner.clone()), + &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")), + ); + h.mount( + &did(fan), + "sh.tangled.graph.follow", + &format!("f{i}"), + follow_body(&owner), + ) + .await; + } + Mock::given(method("GET")) + .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) + .and(query_param("identifier", "did:plc:a")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "did": "did:plc:a", + "handle": "a.example.com", + "pds": "https://pds.example.com" + }))) + .mount(&h.server) + .await; + Mock::given(method("GET")) + .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) + .and(query_param("identifier", "did:plc:b")) + .respond_with(ResponseTemplate::new(404)) + .mount(&h.server) + .await; + + let app = router(h.state.clone()); + let (status, body) = json_response( + app.oneshot(enrich_request(json!({ + "xrpc": "sh.tangled.graph.listFollows", + "params": { "subject": owner.as_ref() }, + "enrich": [{ "source": "sh.tangled.graph.follow:.repo", "type": MINIDOC }] + }))) + .await + .unwrap(), + ) + .await; + + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["data"]["did:plc:a"]["sh.tangled.graph.follow:.repo"][MINIDOC]["handle"], + json!("a.example.com") + ); + // resolution failures are dropped, the client falls back for misses + assert!(body["data"]["did:plc:b"].is_null(), "{body}"); + // the profile owner authored nothing here, so it earns no minidoc + assert!(body["data"]["did:plc:nel"].is_null(), "{body}"); +} + +#[tokio::test] +async fn minidoc_repo_sources_skip_the_author_index() { + let h = Harness::new().await; + let owner = did("did:plc:nel"); + let repo_did = did("did:plc:limpet"); + repo_fixture(&h, &owner, &repo_did).await; + Mock::given(method("GET")) + .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) + .and(query_param("identifier", "did:plc:nel")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "did": "did:plc:nel", + "handle": "nel.example.com" + }))) + .mount(&h.server) + .await; + + let app = router(h.state.clone()); + // sh.tangled.repo has no author mirror; stats would 400, minidocs must not + let (status, body) = json_response( + app.oneshot(enrich_request(json!({ + "xrpc": "sh.tangled.repo.listRepos", + "params": { "subject": owner.as_ref() }, + "enrich": [{ "source": "sh.tangled.repo:.repo", "type": MINIDOC }] + }))) + .await + .unwrap(), + ) + .await; + + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["data"]["did:plc:nel"]["sh.tangled.repo:.repo"][MINIDOC]["handle"], + json!("nel.example.com") + ); + + let app = router(h.state.clone()); + let (status, body) = json_response( + app.oneshot(enrich_request(json!({ + "xrpc": "sh.tangled.repo.listRepos", + "params": { "subject": owner.as_ref() }, + "enrich": [{ "source": "sh.tangled.repo:.repo", "type": COUNT }] + }))) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); } diff --git a/lexicons/com/bad-example/identity/miniDoc.json b/lexicons/com/bad-example/identity/miniDoc.json new file mode 100644 index 00000000..c8dcab40 --- /dev/null +++ b/lexicons/com/bad-example/identity/miniDoc.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.bad-example.identity.miniDoc", + "defs": { + "main": { + "type": "object", + "description": "Minimal identity document, as returned by com.bad-example.identity.resolveMiniDoc.", + "required": ["did", "handle"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of the identity." + }, + "handle": { + "type": "string", + "format": "handle", + "description": "Handle of the identity." + }, + "pds": { + "type": "string", + "description": "PDS endpoint, when known." + } + } + } + } +} diff --git a/lexicons/query/enrichResponse.json b/lexicons/query/enrichResponse.json index c8a9a8f3..2998a066 100644 --- a/lexicons/query/enrichResponse.json +++ b/lexicons/query/enrichResponse.json @@ -4,15 +4,12 @@ "defs": { "main": { "type": "procedure", - "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.", + "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.", "input": { "encoding": "application/json", "schema": { "type": "object", - "required": [ - "xrpc", - "enrich" - ], + "required": ["xrpc", "enrich"], "properties": { "xrpc": { "type": "string", @@ -29,19 +26,19 @@ "type": "ref", "ref": "#linkDescriptor" }, - "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." + "description": "Payloads to compute for each found reference." }, "sources": { "type": "array", "items": { "type": "string" }, - "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." + "description": "Record paths into the inner response restricting which references get payloads. All references are enriched when omitted." }, "viewer": { "type": "string", "format": "did", - "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." + "description": "DID whose own relation to each reference is looked up for viewer payloads. Required when any descriptor requests one." } } } @@ -50,18 +47,15 @@ "encoding": "application/json", "schema": { "type": "object", - "required": [ - "output", - "stats" - ], + "required": ["output", "data"], "properties": { "output": { "type": "unknown", "description": "The inner query's response, unmodified." }, - "stats": { + "data": { "type": "unknown", - "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}." + "description": "Reference (did or at-uri) -> link source -> payload type nsid -> payload. References no descriptor applies to are absent." } } } @@ -69,34 +63,36 @@ "errors": [ { "name": "UnknownQuery", - "description": "The xrpc parameter does not name a query this server can execute." + "description": "The xrpc parameter does not name a query this server can execute" }, { "name": "InvalidSourcePath", - "description": "A sources entry is not valid RecordPath syntax." + "description": "A sources entry is not a valid record path" }, { "name": "InvalidLinkDescriptor", - "description": "An enrich entry names an unknown collection, or a path the server's index does not support." + "description": "An enrich entry names an unknown payload type, collection, or path" } ] }, "linkDescriptor": { "type": "object", - "required": [ - "source", - "type" - ], - "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.", + "required": ["source", "type"], + "description": "One sidecar payload, computed from the records at a link source whose reference equals the found reference.", "properties": { "source": { "type": "string", - "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)." + "description": "Collection whose records are linked, a colon, then an index-backed path (subject or .repo)." }, "type": { "type": "string", - "knownValues": ["count", "distinctAuthors", "viewer"], - "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]." + "knownValues": [ + "sh.tangled.query.enrichResponse#count", + "sh.tangled.query.enrichResponse#distinctAuthors", + "sh.tangled.query.enrichResponse#viewer", + "com.bad-example.identity.miniDoc" + ], + "description": "NSID of the payload type to produce, which also defines the payload's shape. Results land at data[ref][source][type]." } } } diff --git a/web/src/lib/api/enrich.ts b/web/src/lib/api/enrich.ts index 7dbd523c..3e0765eb 100644 --- a/web/src/lib/api/enrich.ts +++ b/web/src/lib/api/enrich.ts @@ -1,26 +1,29 @@ import type { BobbinContext, XrpcRequestInit } from "./client"; import type { Nsid } from "@atcute/lexicons/syntax"; +import type { MiniDoc } from "./identity"; import { jsonPost } from "./_request"; +// payload types, also the keys payloads land under in the data sidecar +export const TYPE_COUNT = "sh.tangled.query.enrichResponse#count"; +export const TYPE_DISTINCT_AUTHORS = "sh.tangled.query.enrichResponse#distinctAuthors"; +export const TYPE_VIEWER = "sh.tangled.query.enrichResponse#viewer"; +export const TYPE_MINIDOC = "com.bad-example.identity.miniDoc"; + export type RecordPath = string & {}; export type EnvelopePath = ".repo" | ".collection" | ".rkey" | "."; export type LinkSource = `${Nsid}:${RecordPath | EnvelopePath}`; export interface LinkDescriptor { source: LinkSource; - type: "count" | "distinctAuthors" | "viewer"; + type: string; } -// 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 -export interface StatsLeaf { - count?: number; - distinctAuthors?: number; - viewer?: string | null; -} -export type Stats = Record>; +// data[ref][source][type] = payload, payload shape depends on the type +export type Sidecar = Record>>; + export interface Enriched { output: O; - stats: Stats; + data: Sidecar; } export interface EnrichRequest { @@ -37,12 +40,27 @@ export const enrich = ( init?: XrpcRequestInit ): Promise> => jsonPost>(ctx, "sh.tangled.query.enrichResponse", req, init); -export const countOf = (stats: Stats, ref: string | undefined, source: LinkSource): number => - (ref !== undefined && stats[ref]?.[source]?.count) || 0; +export const countOf = (data: Sidecar, ref: string | undefined, source: LinkSource): number => + (ref !== undefined && (data[ref]?.[source]?.[TYPE_COUNT] as number | undefined)) || 0; + +export const distinctAuthorsOf = ( + data: Sidecar, + ref: string | undefined, + source: LinkSource +): number => + (ref !== undefined && (data[ref]?.[source]?.[TYPE_DISTINCT_AUTHORS] as number | undefined)) || 0; // undefined means no viewer or inapplicable descriptor, null means no matching record export const viewerUriOf = ( - stats: Stats, + data: Sidecar, ref: string | undefined, source: LinkSource -): string | null | undefined => (ref !== undefined ? stats[ref]?.[source]?.viewer : undefined); +): string | null | undefined => + ref !== undefined ? (data[ref]?.[source]?.[TYPE_VIEWER] as string | null | undefined) : undefined; + +export const miniDocOf = ( + data: Sidecar, + did: string | undefined, + source: LinkSource +): MiniDoc | undefined => + did !== undefined ? (data[did]?.[source]?.[TYPE_MINIDOC] as MiniDoc | undefined) : undefined; diff --git a/web/src/lib/api/lexicons/index.ts b/web/src/lib/api/lexicons/index.ts index 4814a9e1..cbe302ff 100644 --- a/web/src/lib/api/lexicons/index.ts +++ b/web/src/lib/api/lexicons/index.ts @@ -1,3 +1,4 @@ +export * as ShTangledActorDefs from "./types/sh/tangled/actor/defs.js"; export * as ShTangledActorGetProfile from "./types/sh/tangled/actor/getProfile.js"; export * as ShTangledActorGetProfiles from "./types/sh/tangled/actor/getProfiles.js"; export * as ShTangledActorProfile from "./types/sh/tangled/actor/profile.js"; @@ -16,6 +17,7 @@ export * as ShTangledFeedCountReactionsBy from "./types/sh/tangled/feed/countRea export * as ShTangledFeedCountStars from "./types/sh/tangled/feed/countStars.js"; export * as ShTangledFeedCountStarsBy from "./types/sh/tangled/feed/countStarsBy.js"; export * as ShTangledFeedGetStar from "./types/sh/tangled/feed/getStar.js"; +export * as ShTangledFeedGetTimeline from "./types/sh/tangled/feed/getTimeline.js"; export * as ShTangledFeedListComments from "./types/sh/tangled/feed/listComments.js"; export * as ShTangledFeedListCommentsBy from "./types/sh/tangled/feed/listCommentsBy.js"; export * as ShTangledFeedListReactions from "./types/sh/tangled/feed/listReactions.js"; @@ -116,6 +118,7 @@ export * as ShTangledRepoCountPulls from "./types/sh/tangled/repo/countPulls.js" export * as ShTangledRepoCountPullsBy from "./types/sh/tangled/repo/countPullsBy.js"; export * as ShTangledRepoCountRepos from "./types/sh/tangled/repo/countRepos.js"; export * as ShTangledRepoCreate from "./types/sh/tangled/repo/create.js"; +export * as ShTangledRepoDefs from "./types/sh/tangled/repo/defs.js"; export * as ShTangledRepoDelete from "./types/sh/tangled/repo/delete.js"; export * as ShTangledRepoDeleteBranch from "./types/sh/tangled/repo/deleteBranch.js"; export * as ShTangledRepoDescribeRepo from "./types/sh/tangled/repo/describeRepo.js"; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/publicKey/listKeys.ts b/web/src/lib/api/lexicons/types/sh/tangled/publicKey/listKeys.ts new file mode 100644 index 00000000..88509817 --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/publicKey/listKeys.ts @@ -0,0 +1,75 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _listItemSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.publicKey.listKeys#listItem"), + ), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + uri: /*#__PURE__*/ v.resourceUriString(), + /** + * Embedded sh.tangled.publicKey record + */ + value: /*#__PURE__*/ v.unknown(), +}); +const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.publicKey.listKeys", { + params: /*#__PURE__*/ v.object({ + /** + * Pagination cursor + */ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * @minimum 1 + * @maximum 1000 + * @default 50 + */ + limit: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ + /*#__PURE__*/ v.integerRange(1, 1000), + ]), + 50, + ), + /** + * Sort direction by createdAt. + * @default "desc" + */ + order: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>(), + "desc", + ), + /** + * Owner DID whose public-key records to list. + */ + subject: /*#__PURE__*/ v.didString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + get items() { + return /*#__PURE__*/ v.array(listItemSchema); + }, + }), + }, +}); + +type listItem$schematype = typeof _listItemSchema; +type main$schematype = typeof _mainSchema; + +export interface listItemSchema extends listItem$schematype {} +export interface mainSchema extends main$schematype {} + +export const listItemSchema = _listItemSchema as listItemSchema; +export const mainSchema = _mainSchema as mainSchema; + +export interface ListItem extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "sh.tangled.publicKey.listKeys": mainSchema; + } +} diff --git a/web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts b/web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts index 81196fa8..d5a5b9b5 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts @@ -7,14 +7,18 @@ const _linkDescriptorSchema = /*#__PURE__*/ v.object({ /*#__PURE__*/ v.literal("sh.tangled.query.enrichResponse#linkDescriptor"), ), /** - * 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). + * Link source: collection whose records are linked, a colon, then an index-backed path (subject or .repo). */ source: /*#__PURE__*/ v.string(), /** - * 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]. + * NSID of the payload type to produce, which also defines the payload's shape. Results land at data[ref][source][type]. */ type: /*#__PURE__*/ v.string< - "count" | "distinctAuthors" | "viewer" | (string & {}) + | "com.bad-example.identity.miniDoc" + | "sh.tangled.query.enrichResponse#count" + | "sh.tangled.query.enrichResponse#distinctAuthors" + | "sh.tangled.query.enrichResponse#viewer" + | (string & {}) >(), }); const _mainSchema = /*#__PURE__*/ v.procedure( @@ -25,7 +29,7 @@ const _mainSchema = /*#__PURE__*/ v.procedure( type: "lex", schema: /*#__PURE__*/ v.object({ /** - * 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. + * Payloads to compute for each found reference. */ get enrich() { return /*#__PURE__*/ v.array(linkDescriptorSchema); @@ -35,13 +39,13 @@ const _mainSchema = /*#__PURE__*/ v.procedure( */ params: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), /** - * 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. + * Record paths into the inner response restricting which references get payloads. All references are enriched when omitted. */ sources: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), ), /** - * 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. + * DID whose own relation to each reference is looked up for viewer payloads. Required when any descriptor requests one. */ viewer: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), /** @@ -54,13 +58,13 @@ const _mainSchema = /*#__PURE__*/ v.procedure( type: "lex", schema: /*#__PURE__*/ v.object({ /** - * The inner query's response, unmodified. + * Reference (did or at-uri) -> link source -> payload type nsid -> payload. References no descriptor applies to are absent. */ - output: /*#__PURE__*/ v.unknown(), + data: /*#__PURE__*/ v.unknown(), /** - * 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}. + * The inner query's response, unmodified. */ - stats: /*#__PURE__*/ v.unknown(), + output: /*#__PURE__*/ v.unknown(), }), }, }, diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts index e96c771c..63b12e53 100644 --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -1,12 +1,23 @@ // page fetchers for the profile tabs, shared between the route load (first -// page) and the tab components (load more). pass a shared IdentityCache to -// dedupe across pages +// page) and the tab components (load more). identities come from the enrich +// sidecar's minidoc payloads, resolveMiniDoc only fires for sidecar misses import type { BobbinContext } from "$lib/api/client"; import type { Did } from "@atcute/lexicons/syntax"; -import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; +import { + enrich, + countOf, + viewerUriOf, + miniDocOf, + TYPE_COUNT, + TYPE_VIEWER, + TYPE_MINIDOC, + type Sidecar, + type LinkDescriptor, + type LinkSource +} from "$lib/api/enrich"; import { fetchPage } from "$lib/api/pagination"; -import { IdentityCache } from "$lib/api/identity"; +import { IdentityCache, type MiniDoc } from "$lib/api/identity"; import type { RecordView, RepoRecord } from "$lib/api/records"; import type { SearchPage } from "$lib/api/search"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; @@ -35,13 +46,30 @@ type RecordPage = { cursor?: string; }; -const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; -const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; +const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: TYPE_COUNT }; +const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: TYPE_VIEWER }; const FOLLOW_STATS: LinkDescriptor[] = [ - { source: "sh.tangled.graph.follow:subject", type: "count" }, - { source: "sh.tangled.graph.follow:.repo", type: "count" } + { source: "sh.tangled.graph.follow:subject", type: TYPE_COUNT }, + { source: "sh.tangled.graph.follow:.repo", type: TYPE_COUNT } ]; -const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; +const FOLLOW_VIEWER: LinkDescriptor = { + source: "sh.tangled.graph.follow:subject", + type: TYPE_VIEWER +}; +const FOLLOWER_DOCS: LinkDescriptor = { + source: "sh.tangled.graph.follow:.repo", + type: TYPE_MINIDOC +}; +const FOLLOWING_DOCS: LinkDescriptor = { + source: "sh.tangled.graph.follow:subject", + type: TYPE_MINIDOC +}; +const REPO_OWNER_DOCS: LinkDescriptor = { source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC }; +const STAR_SUBJECT_DOCS: LinkDescriptor = { + source: "sh.tangled.feed.star:subject", + type: TYPE_MINIDOC +}; +const VOUCHER_DOCS: LinkDescriptor = { source: "sh.tangled.graph.vouch:.repo", type: TYPE_MINIDOC }; const starDescriptors = (viewerDid: string | undefined) => viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; @@ -59,14 +87,24 @@ const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { }; }; -const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { +const resolveRepoCard = (item: ListItem, ownerHandle: string, data: Sidecar): RepoCardData => { const repo = toRepoCard(item, ownerHandle); if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; - const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); - const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); + const stars = countOf(data, repo.repoDid, STAR_COUNT.source); + const viewerUri = viewerUriOf(data, repo.repoDid, STAR_VIEWER.source); return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; }; +const docOrResolve = ( + data: Sidecar, + source: LinkSource, + cache: IdentityCache, + did: string +): Promise => { + const doc = miniDocOf(data, did, source); + return doc ? Promise.resolve(doc) : cache.resolve(did).catch(() => null); +}; + const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { const value = item.value as ShTangledString.Main; return { @@ -79,25 +117,26 @@ const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { }; }; -// the stats sidecar already carries follower counts and viewer status, the +// the data sidecar already carries follower counts and viewer status, the // only extra cost is one miniDoc per did const resolvePeople = async ( dids: string[], - stats: Stats, + data: Sidecar, + docSource: LinkSource, cache: IdentityCache, viewerDid?: string ): Promise => { const unique = [...new Set(dids)]; - const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); + const docs = await Promise.all(unique.map((did) => docOrResolve(data, docSource, cache, did))); const byDid = new Map(); unique.forEach((did, index) => { const doc = docs[index]; - const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); - const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); + const followers = countOf(data, did, "sh.tangled.graph.follow:subject"); + const following = countOf(data, did, "sh.tangled.graph.follow:.repo"); const isSelf = viewerDid === did; - const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); + const viewerUri = viewerUriOf(data, did, FOLLOW_VIEWER.source); const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; byDid.set( did, @@ -119,13 +158,19 @@ const resolvePeople = async ( const resolveVouches = async ( items: ListItem[], direction: "incoming" | "outgoing", + data: Sidecar | null, cache: IdentityCache ): Promise => { return Promise.all( items.map(async (item): Promise => { const value = item.value as VouchRecord; const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); - const doc = await cache.resolve(otherDid).catch(() => null); + // outgoing vouches name the subject in the rkey, which the sidecar + // can't see. those still resolve client-side + const doc = + (direction === "incoming" && data + ? miniDocOf(data, otherDid, VOUCHER_DOCS.source) + : undefined) ?? (await cache.resolve(otherDid).catch(() => null)); return { uri: item.uri, did: otherDid, @@ -141,6 +186,7 @@ const resolveVouches = async ( const resolveStars = async ( ctx: BobbinContext, + starData: Sidecar, items: ListItem[], cache: IdentityCache, viewerDid?: string @@ -157,10 +203,10 @@ const resolveStars = async ( ? await enrich<{ items: ListItem[] }>(ctx, { xrpc: "sh.tangled.repo.getReposByRepoDids", params: { dids: repoDids }, - enrich: starDescriptors(viewerDid), + enrich: [...starDescriptors(viewerDid), REPO_OWNER_DOCS], ...(viewerDid ? { viewer: viewerDid } : {}) }) - : { output: { items: [] }, stats: {} as Stats }; + : { output: { items: [] }, data: {} as Sidecar }; const reposByDid = new Map( enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) ); @@ -172,17 +218,17 @@ const resolveStars = async ( const repo = reposByDid.get(subject.did); if (!repo) return null; const ownerDid = didFromUri(repo.uri); - const owner = await cache.resolve(ownerDid).catch(() => null); + const owner = await docOrResolve(enriched.data, REPO_OWNER_DOCS.source, cache, ownerDid); return { kind: "repo", uri: item.uri, createdAt: value.createdAt, - repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) + repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.data) }; } if (subject && "uri" in subject && subject.uri) { const ownerDid = didFromUri(subject.uri); - const owner = await cache.resolve(ownerDid).catch(() => null); + const owner = await docOrResolve(starData, STAR_SUBJECT_DOCS.source, cache, ownerDid); return { kind: "string", uri: item.uri, @@ -219,7 +265,7 @@ export const fetchReposPage = async ( ...(viewerDid ? { viewer: viewerDid } : {}) }); return { - items: enriched.output.items.map((item) => resolveRepoCard(item, handle, enriched.stats)), + items: enriched.output.items.map((item) => resolveRepoCard(item, handle, enriched.data)), cursor: enriched.output.cursor }; } @@ -230,7 +276,7 @@ export const fetchReposPage = async ( ...(viewerDid ? { viewer: viewerDid } : {}) }); return { - items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)), + items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.data)), cursor: enriched.output.cursor ?? undefined }; }; @@ -266,14 +312,20 @@ export const fetchStarredPage = async ( ctx: BobbinContext, { did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions ): Promise> => { - const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { - subject: did as Did, - limit, - cursor + const page = await enrich>(ctx, { + xrpc: "sh.tangled.feed.listStarsBy", + params: { subject: did, limit, cursor }, + enrich: [STAR_SUBJECT_DOCS] }); return { - items: await resolveStars(ctx, page.items, cache ?? new IdentityCache(ctx), viewerDid), - cursor: page.cursor + items: await resolveStars( + ctx, + page.data, + page.output.items, + cache ?? new IdentityCache(ctx), + viewerDid + ), + cursor: page.output.cursor }; }; @@ -290,11 +342,12 @@ export const fetchPeoplePage = async ( ctx: BobbinContext, { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions ): Promise> => { + const docs = direction === "followers" ? FOLLOWER_DOCS : FOLLOWING_DOCS; const enriched = await enrich>(ctx, { xrpc: direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy", params: { subject: did, limit, cursor }, - enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, + enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER, docs] : [...FOLLOW_STATS, docs], ...(viewerDid ? { viewer: viewerDid } : {}) }); const dids = @@ -302,7 +355,13 @@ export const fetchPeoplePage = async ( ? enriched.output.items.map((item) => didFromUri(item.uri)) : enriched.output.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); return { - items: await resolvePeople(dids, enriched.stats, cache ?? new IdentityCache(ctx), viewerDid), + items: await resolvePeople( + dids, + enriched.data, + docs.source, + cache ?? new IdentityCache(ctx), + viewerDid + ), cursor: enriched.output.cursor }; }; @@ -330,13 +389,13 @@ export const fetchVouchesPage = async ( { did, cursors = {}, cache, limit = PROFILE_PAGE_LIMIT }: VouchesPageOptions ): Promise => { const identity = cache ?? new IdentityCache(ctx); - const [incomingPage, outgoingPage] = await Promise.all([ + const [incoming, outgoingPage] = await Promise.all([ cursors.incoming === null - ? { items: [], cursor: undefined } - : fetchPage(ctx, "sh.tangled.graph.listVouches", { - subject: did as Did, - limit, - cursor: cursors.incoming + ? { output: { items: [], cursor: undefined }, data: {} as Sidecar } + : enrich>(ctx, { + xrpc: "sh.tangled.graph.listVouches", + params: { subject: did, limit, cursor: cursors.incoming }, + enrich: [VOUCHER_DOCS] }), cursors.outgoing === null ? { items: [], cursor: undefined } @@ -346,16 +405,16 @@ export const fetchVouchesPage = async ( cursor: cursors.outgoing }) ]); - const [incoming, outgoing] = await Promise.all([ - resolveVouches(incomingPage.items, "incoming", identity), - resolveVouches(outgoingPage.items, "outgoing", identity) + const [incomingVouches, outgoingVouches] = await Promise.all([ + resolveVouches(incoming.output.items, "incoming", incoming.data, identity), + resolveVouches(outgoingPage.items, "outgoing", null, identity) ]); return { - items: [...incoming, ...outgoing].sort( + items: [...incomingVouches, ...outgoingVouches].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ), cursors: { - incoming: incomingPage.cursor ?? null, + incoming: incoming.output.cursor ?? null, outgoing: outgoingPage.cursor ?? null } }; @@ -377,7 +436,7 @@ export const fetchPinned = async ( const dids = keys.filter((key) => key.startsWith("did:")); const uris = keys.filter((key) => key.startsWith("at://")); const descriptors = starDescriptors(viewerDid); - const empty = { output: { items: [] as ListItem[] }, stats: {} as Stats }; + const empty = { output: { items: [] as ListItem[] }, data: {} as Sidecar }; const [byDid, byUri] = await Promise.all([ dids.length > 0 ? enrich<{ items: ListItem[] }>(ctx, { @@ -399,10 +458,10 @@ export const fetchPinned = async ( const cards = new Map(); for (const item of byDid.output.items) { const repoDid = (item.value as RepoRecord).repoDid; - if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.stats)); + if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.data)); } for (const item of byUri.output.items) { - cards.set(item.uri, resolveRepoCard(item, handle, byUri.stats)); + cards.set(item.uri, resolveRepoCard(item, handle, byUri.data)); } return keys .map((key) => cards.get(key)) diff --git a/web/src/routes/[handle]/[repo]/issues/+page.ts b/web/src/routes/[handle]/[repo]/issues/+page.ts index 8596ca8e..93f2e887 100644 --- a/web/src/routes/[handle]/[repo]/issues/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/+page.ts @@ -1,7 +1,8 @@ import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; +import { enrich, miniDocOf, TYPE_MINIDOC } from "$lib/api/enrich"; import { IdentityCache } from "$lib/api/identity"; -import { listIssues } from "$lib/api/records"; +import type { IssueListPage } from "$lib/api/records"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; import type { IssueSummary } from "$lib/components/repo/types"; import type { PageLoad } from "./$types"; @@ -24,16 +25,22 @@ export const load: PageLoad = async (event) => { const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); const [page, closed] = await Promise.all([ - listIssues(ctx, repoDid, { state }), - // the layout only knows the open count; the closed tab needs its own + enrich(ctx, { + xrpc: "sh.tangled.repo.listIssues", + params: { subject: repoDid, state }, + enrich: [{ source: "sh.tangled.repo.issue:.repo", type: TYPE_MINIDOC }] + }), + // the layout only knows the open count, the closed tab needs its own count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }).catch(() => null) ]); const identity = new IdentityCache(ctx); const issues: IssueSummary[] = await Promise.all( - page.items.map(async (item): Promise => { + page.output.items.map(async (item): Promise => { const authorDid = didFromUri(item.uri); - const author = await identity.resolve(authorDid).catch(() => null); + const author = + miniDocOf(page.data, authorDid, "sh.tangled.repo.issue:.repo") ?? + (await identity.resolve(authorDid).catch(() => null)); return { uri: item.uri, rkey: rkeyFromUri(item.uri), diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts index dcf87dff..2f3fb24c 100644 --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts @@ -1,11 +1,12 @@ import { error } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; -import { IdentityCache } from "$lib/api/identity"; +import { enrich, miniDocOf, TYPE_MINIDOC, type LinkSource, type Sidecar } from "$lib/api/enrich"; +import { IdentityCache, type MiniDoc } from "$lib/api/identity"; import { - getIssue, - listComments, listIssueStates, - listReactions, + type CommentListPage, + type IssueRecord, + type ReactionListPage, type RecordView, type ReactionRecord } from "$lib/api/records"; @@ -23,11 +24,24 @@ export const load: PageLoad = async (event) => { const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - const record = await getIssue(ctx, uri).catch(() => null); - if (!record) error(404, "Issue not found"); + const issuePage = await enrich>(ctx, { + xrpc: "sh.tangled.repo.getIssue", + params: { issue: uri }, + enrich: [{ source: "sh.tangled.repo.issue:.repo", type: TYPE_MINIDOC }] + }).catch(() => null); + if (!issuePage) error(404, "Issue not found"); + const record = issuePage.output; const authorDid = didFromUri(record.uri); const identities = new IdentityCache(ctx); + const docOrResolve = ( + data: Sidecar, + source: LinkSource, + did: string + ): Promise => { + const doc = miniDocOf(data, did, source); + return doc ? Promise.resolve(doc) : identities.resolve(did).catch(() => null); + }; const markupOpts = { repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, ref: parent.repo.defaultBranch, @@ -35,9 +49,13 @@ export const load: PageLoad = async (event) => { }; const [author, states, comments] = await Promise.all([ - identities.resolve(authorDid).catch(() => null), + docOrResolve(issuePage.data, "sh.tangled.repo.issue:.repo", authorDid), listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null), - listComments(ctx, record.uri, { order: "asc", limit: 100 }).catch(() => null) + enrich(ctx, { + xrpc: "sh.tangled.feed.listComments", + params: { subject: record.uri, order: "asc", limit: 100 }, + enrich: [{ source: "sh.tangled.feed.comment:.repo", type: TYPE_MINIDOC }] + }).catch(() => null) ]); const latest = states?.items[0]?.value.state; @@ -45,29 +63,43 @@ export const load: PageLoad = async (event) => { const body = record.value.body ?? ""; const bodyHtml = body ? await renderMarkup(body, markupOpts) : null; const viewerDid = parent.auth?.did; - const commentItems = comments?.items ?? []; + const commentItems = comments?.output.items ?? []; // reactions: one listReactions per subject (the issue itself + each comment). // bobbin has no batch-by-subject query and enrich can't break counts down by - // emoji, so we fan out and group client-side. empty subjects come back empty. + // emoji, so fan out and group client-side. reactor docs ride along in the + // same enrich calls const subjects = [record.uri, ...commentItems.map((item) => item.uri)]; const reactionPages = await Promise.all( subjects.map((subject) => - listReactions(ctx, subject, { order: "asc", limit: 100 }).catch(() => null) + enrich(ctx, { + xrpc: "sh.tangled.feed.listReactions", + params: { subject, order: "asc", limit: 100 }, + enrich: [{ source: "sh.tangled.feed.reaction:.repo", type: TYPE_MINIDOC }] + }).catch(() => null) ) ); const reactionsBySubject = new Map[]>(); - subjects.forEach((subject, i) => reactionsBySubject.set(subject, reactionPages[i]?.items ?? [])); + subjects.forEach((subject, i) => + reactionsBySubject.set(subject, reactionPages[i]?.output.items ?? []) + ); - // resolve every reactor's handle once for the whole page (IdentityCache dedups) const reactorDids = new Set(); for (const items of reactionsBySubject.values()) { for (const item of items) reactorDids.add(didFromUri(item.uri)); } + // a reactor's doc can land in any subject's sidecar + const reactorDoc = (did: string): MiniDoc | undefined => { + for (const page of reactionPages) { + const doc = page && miniDocOf(page.data, did, "sh.tangled.feed.reaction:.repo"); + if (doc) return doc; + } + return undefined; + }; const reactorHandles = new Map(); await Promise.all( [...reactorDids].map(async (did) => { - const doc = await identities.resolve(did).catch(() => null); + const doc = reactorDoc(did) ?? (await identities.resolve(did).catch(() => null)); reactorHandles.set(did, doc?.handle ?? did); }) ); @@ -83,7 +115,7 @@ export const load: PageLoad = async (event) => { const commentDid = didFromUri(item.uri); const commentBody = item.value.body?.text ?? ""; const [doc, commentBodyHtml] = await Promise.all([ - identities.resolve(commentDid).catch(() => null), + docOrResolve(comments?.data ?? {}, "sh.tangled.feed.comment:.repo", commentDid), commentBody ? renderMarkup(commentBody, markupOpts) : Promise.resolve(null) ]); return {