From deb1fd8b9041fd7bfeb659822ff92957d2af00cb Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 04 Aug 2026 12:41:35 +0000 Subject: [PATCH] bobbin/crates/{bobbin,ingest,resolver,xrpc},web: only use indexed did docs for enriching things, remove cache limit Signed-off-by: dawn --- Cargo.lock | 1 + bobbin/example.toml | 8 -------- bobbin/crates/bobbin-sim/Cargo.toml | 1 + bobbin/crates/bobbin-sim/src/runtime.rs | 5 +---- bobbin/crates/bobbin/src/config.rs | 12 ------------ bobbin/crates/bobbin/src/main.rs | 1 - bobbin/crates/ingest/examples/smoke.rs | 5 +---- bobbin/crates/ingest/src/lib.rs | 25 ++++++------------------- bobbin/crates/resolver/src/identity.rs | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------ bobbin/crates/resolver/src/lib.rs | 3 +-- bobbin/crates/xrpc/src/enrich.rs | 24 +++++++++--------------- bobbin/crates/xrpc/src/feed.rs | 9 ++++----- bobbin/crates/xrpc/src/lib.rs | 5 +---- bobbin/crates/xrpc/tests/enrich.rs | 44 ++++++++++++++------------------------------ web/src/lib/api/enrich.ts | 5 ++++- web/src/lib/api/identity.ts | 2 ++ bobbin/crates/bobbin/src/mem/report.rs | 2 -- web/src/lib/components/profile/pages.test.ts | 25 +++++++++++++++++++++++++ web/src/lib/components/profile/pages.ts | 138 +++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------- web/src/routes/[handle]/[repo]/+layout.ts | 18 +++++++++++++----- web/src/lib/components/profile/tabs/PeopleTab.svelte | 7 +------ web/src/lib/components/profile/tabs/StarredTab.svelte | 7 +------ web/src/routes/[handle]/[repo]/issues/+page.ts | 36 +++++++++++++++--------------------- web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts | 41 +++++++++++++++-------------------------- 24 file(s) changed, 216 insertion(s)(+), 354 deletion(s)(-) diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -857,6 +857,7 @@ "bobbin-edge-index", "bobbin-ingest", "bobbin-record-lru", + "bobbin-resolver", "bobbin-runtime", "bobbin-search", "bobbin-slingshot-client", diff --git a/bobbin/example.toml b/bobbin/example.toml --- a/bobbin/example.toml +++ b/bobbin/example.toml @@ -135,14 +135,6 @@ # Default value: 67108864 #lru_bytes = 67108864 -[identity_cache] -# Maximum number of DID entries retained by the in-process identity cache. -# -# Can also be specified via environment variable `BOBBIN_IDENTITY_CACHE_ENTRIES`. -# -# Default value: 100000 -#max_entries = 100000 - [search] # The heap size in bytes for the in-mem tantivy writer. Larger values # trade RAM for fewer segment merges - the index itself lives in diff --git a/bobbin/crates/bobbin-sim/Cargo.toml b/bobbin/crates/bobbin-sim/Cargo.toml --- a/bobbin/crates/bobbin-sim/Cargo.toml +++ b/bobbin/crates/bobbin-sim/Cargo.toml @@ -16,6 +16,7 @@ bobbin-edge-index = { workspace = true } bobbin-ingest = { workspace = true } bobbin-record-lru = { workspace = true } +bobbin-resolver = { workspace = true } bobbin-runtime = { workspace = true } bobbin-search = { workspace = true } bobbin-slingshot-client = { workspace = true } diff --git a/bobbin/crates/bobbin-sim/src/runtime.rs b/bobbin/crates/bobbin-sim/src/runtime.rs --- a/bobbin/crates/bobbin-sim/src/runtime.rs +++ b/bobbin/crates/bobbin-sim/src/runtime.rs @@ -116,10 +116,7 @@ search: Arc::new(NoopSearchSink), records: records.clone() as Arc, resolver: resolver.clone(), - identity: Arc::new(IdentityResolver::detached( - hasher.clone(), - bobbin_resolver::DEFAULT_IDENTITY_CACHE_ENTRIES, - )), + identity: Arc::new(IdentityResolver::detached(hasher.clone())), clock: clock.clone(), entropy: entropy.clone(), ws: mem_ws, diff --git a/bobbin/crates/bobbin/src/config.rs b/bobbin/crates/bobbin/src/config.rs --- a/bobbin/crates/bobbin/src/config.rs +++ b/bobbin/crates/bobbin/src/config.rs @@ -26,7 +26,6 @@ "backpressure.reserved_index_bytes", "slingshot.url", "record_cache.lru_bytes", - "identity_cache.max_entries", "search.heap_bytes", "knot.allow_private", "knot.require_https", @@ -51,7 +50,6 @@ "BOBBIN_BACKPRESSURE_RESERVED_INDEX_BYTES", "BOBBIN_SLINGSHOT_URL", "BOBBIN_RECORD_LRU_BYTES", - "BOBBIN_IDENTITY_CACHE_ENTRIES", "BOBBIN_SEARCH_HEAP_BYTES", "BOBBIN_KNOT_ALLOW_PRIVATE", "BOBBIN_KNOT_REQUIRE_HTTPS", @@ -79,9 +77,6 @@ #[config(nested)] pub record_cache: RecordCacheConfig, - - #[config(nested)] - pub identity_cache: IdentityCacheConfig, #[config(nested)] pub search: SearchConfig, @@ -239,13 +234,6 @@ /// LRU policy keyed on URI plus payload length. #[config(env = "BOBBIN_RECORD_LRU_BYTES", default = 67_108_864)] pub lru_bytes: u64, -} - -#[derive(Debug, Config)] -pub struct IdentityCacheConfig { - /// Maximum number of (mini) DID doc entries that the identity cache will hold. - #[config(env = "BOBBIN_IDENTITY_CACHE_ENTRIES", default = 100_000)] - pub max_entries: usize, } #[derive(Debug, Config)] diff --git a/bobbin/crates/bobbin/src/main.rs b/bobbin/crates/bobbin/src/main.rs --- a/bobbin/crates/bobbin/src/main.rs +++ b/bobbin/crates/bobbin/src/main.rs @@ -190,7 +190,6 @@ let identity = Arc::new(IdentityResolver::with_slingshot( slingshot.clone(), hasher.clone(), - cfg.identity_cache.max_entries, )); let mut resolver_opts = ResolverOptions::default(); // NOTE: see https://tangled.org/nonbinary.computer/jacquard/issues/39. diff --git a/bobbin/crates/ingest/examples/smoke.rs b/bobbin/crates/ingest/examples/smoke.rs --- a/bobbin/crates/ingest/examples/smoke.rs +++ b/bobbin/crates/ingest/examples/smoke.rs @@ -40,10 +40,7 @@ search: Arc::new(NoopSearchSink), records: Arc::new(NoopRecordStore) as Arc, resolver: Arc::new(RepoIdResolver::detached(hasher.clone())), - identity: Arc::new(IdentityResolver::detached( - hasher, - bobbin_resolver::DEFAULT_IDENTITY_CACHE_ENTRIES, - )), + identity: Arc::new(IdentityResolver::detached(hasher)), clock: Arc::new(SystemClock::new()), entropy: Arc::new(OsEntropy), ws: TungsteniteWs::shared(), diff --git a/bobbin/crates/ingest/src/lib.rs b/bobbin/crates/ingest/src/lib.rs --- a/bobbin/crates/ingest/src/lib.rs +++ b/bobbin/crates/ingest/src/lib.rs @@ -9,8 +9,6 @@ }; use bobbin_knot_ingest::{CapabilityGate, KnotRegistry}; use bobbin_record_lru::RecordStore; -#[cfg(test)] -use bobbin_resolver::DEFAULT_IDENTITY_CACHE_ENTRIES; use bobbin_resolver::{ IdentityResolver, NormalizeRepoRefs, decode_canon_or_upgrade_bytes, synthesize_created_at, }; @@ -1502,8 +1500,7 @@ }; let pending = claim_pending(prepare_frame(frame, &ctx, now).await, &ctx).await; let pending = resolve_pending(pending, &ctx).await; - let identity = - IdentityResolver::detached(RuntimeHasher::default(), DEFAULT_IDENTITY_CACHE_ENTRIES); + let identity = IdentityResolver::detached(RuntimeHasher::default()); commit_pending( pending, store, @@ -2572,8 +2569,7 @@ #[tokio::test] async fn identity_frames_update_identity_resolver_lifecycle() { let (store, issue_states, pull_statuses, coverage, resolver) = fresh(); - let identity = - IdentityResolver::detached(RuntimeHasher::default(), DEFAULT_IDENTITY_CACHE_ENTRIES); + let identity = IdentityResolver::detached(RuntimeHasher::default()); let records = NoopRecordStore; let search = NoopSearchSink; let ctx = PipelineCtx { @@ -2615,8 +2611,7 @@ .await; let doc = identity - .resolve_by_did(&Did::new_static("did:plc:olaren").unwrap()) - .await + .get_by_did(&Did::new_static("did:plc:olaren").unwrap()) .expect("identity frame should seed the resolver"); assert_eq!(doc.handle.as_ref(), "olaren.dev"); @@ -2646,9 +2641,7 @@ .await; assert_eq!( - identity - .resolve_by_did(&Did::new_static("did:plc:olaren").unwrap()) - .await, + identity.get_by_did(&Did::new_static("did:plc:olaren").unwrap()), Err(bobbin_resolver::IdentityResolveError::NotFound) ); } @@ -2708,10 +2701,7 @@ search: Arc::new(NoopSearchSink), records: Arc::new(NoopRecordStore) as Arc, resolver: Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - identity: Arc::new(IdentityResolver::detached( - RuntimeHasher::default(), - DEFAULT_IDENTITY_CACHE_ENTRIES, - )), + identity: Arc::new(IdentityResolver::detached(RuntimeHasher::default())), clock: Arc::new(SystemClock::new()), entropy: Arc::new(OsEntropy), ws: TungsteniteWs::shared(), @@ -3672,10 +3662,7 @@ search: Arc::new(NoopSearchSink), records: capturing.clone() as Arc, resolver, - identity: Arc::new(IdentityResolver::detached( - RuntimeHasher::default(), - DEFAULT_IDENTITY_CACHE_ENTRIES, - )), + identity: Arc::new(IdentityResolver::detached(RuntimeHasher::default())), clock, entropy: Arc::new(OsEntropy), ws: TungsteniteWs::shared(), diff --git a/bobbin/crates/resolver/src/identity.rs b/bobbin/crates/resolver/src/identity.rs --- a/bobbin/crates/resolver/src/identity.rs +++ b/bobbin/crates/resolver/src/identity.rs @@ -7,13 +7,11 @@ use jacquard_common::types::did::Did; use jacquard_common::types::ident::AtIdentifier; use jacquard_common::types::string::Handle; -use scc::hash_cache::Entry as CacheEntry; -use scc::{HashCache as SccCache, HashMap as SccMap}; +use scc::HashMap as SccMap; +use scc::hash_map::Entry as MapEntry; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::sync::OnceCell; - -pub const DEFAULT_IDENTITY_CACHE_ENTRIES: usize = 100_000; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -66,7 +64,6 @@ #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct IdentityResolverStatsSnapshot { pub entries: usize, - pub capacity: usize, pub hits: u64, pub misses: u64, pub upstream_requests: u64, @@ -79,8 +76,9 @@ upstream_requests: AtomicU64, } +// in the future this would spill to disk probably pub struct IdentityResolver { - by_did: SccCache, IdentityState, RuntimeHasher>, + by_did: SccMap, IdentityState, RuntimeHasher>, by_handle: SccMap, Did, RuntimeHasher>, in_flight: SccMap>>, RuntimeHasher>, slingshot: Option, @@ -88,21 +86,17 @@ } impl IdentityResolver { - pub fn with_slingshot( - slingshot: SlingshotClient, - hasher: RuntimeHasher, - capacity: usize, - ) -> Self { - Self::new(Some(slingshot), hasher, capacity) + pub fn with_slingshot(slingshot: SlingshotClient, hasher: RuntimeHasher) -> Self { + Self::new(Some(slingshot), hasher) } - pub fn detached(hasher: RuntimeHasher, capacity: usize) -> Self { - Self::new(None, hasher, capacity) + pub fn detached(hasher: RuntimeHasher) -> Self { + Self::new(None, hasher) } - fn new(slingshot: Option, hasher: RuntimeHasher, capacity: usize) -> Self { + fn new(slingshot: Option, hasher: RuntimeHasher) -> Self { Self { - by_did: SccCache::with_capacity_and_hasher(0, capacity, hasher.clone()), + by_did: SccMap::with_hasher(hasher.clone()), by_handle: SccMap::with_hasher(hasher.clone()), in_flight: SccMap::with_hasher(hasher), slingshot, @@ -113,7 +107,6 @@ pub fn stats(&self) -> IdentityResolverStatsSnapshot { IdentityResolverStatsSnapshot { entries: self.by_did.len(), - capacity: *self.by_did.capacity_range().end(), hits: self.stats.hits.load(Ordering::Relaxed), misses: self.stats.misses.load(Ordering::Relaxed), upstream_requests: self.stats.upstream_requests.load(Ordering::Relaxed), @@ -121,10 +114,9 @@ } pub fn observe(&self, did: Did, handle: Handle) { - let mut evicted = None; let mut previous_handle = None; match self.by_did.entry_sync(did.clone()) { - CacheEntry::Occupied(mut occupied) => { + MapEntry::Occupied(mut occupied) => { let (pds, fetched) = match occupied.get() { IdentityState::Observed(previous) => { previous_handle = Some(previous.handle.clone()); @@ -141,43 +133,36 @@ handle: handle.clone(), pds, }; - occupied.put(if fetched { + occupied.insert(if fetched { IdentityState::Fetched(doc) } else { IdentityState::Observed(doc) }); } - CacheEntry::Vacant(vacant) => { - let (removed, occupied) = vacant.put_entry(IdentityState::Observed(MiniDoc { + MapEntry::Vacant(vacant) => { + vacant.insert_entry(IdentityState::Observed(MiniDoc { did: did.clone(), handle: handle.clone(), pds: None, })); - evicted = removed; - drop(occupied); } } self.remove_by_handle_if_owned(&did, previous_handle.as_ref()); - self.remove_by_handle_for_removed(evicted); self.insert_by_handle(did, handle); } pub fn deactivate(&self, did: Did) { - let mut evicted = None; let mut previous_handle = None; match self.by_did.entry_sync(did.clone()) { - CacheEntry::Occupied(mut occupied) => { + MapEntry::Occupied(mut occupied) => { previous_handle = occupied.get().doc().map(|doc| doc.handle.clone()); - occupied.put(IdentityState::Inactive); + occupied.insert(IdentityState::Inactive); } - CacheEntry::Vacant(vacant) => { - let (removed, occupied) = vacant.put_entry(IdentityState::Inactive); - evicted = removed; - drop(occupied); + MapEntry::Vacant(vacant) => { + vacant.insert_entry(IdentityState::Inactive); } } self.remove_by_handle_if_owned(&did, previous_handle.as_ref()); - self.remove_by_handle_for_removed(evicted); } fn remove_by_handle_if_owned( @@ -191,7 +176,7 @@ self.by_handle.remove_if_sync(handle, |owner| owner == did); } - fn remove_by_handle_for_removed(&self, removed: Option<(Did, IdentityState)>) { + fn remove_by_handle_for_removed_did(&self, removed: Option<(Did, IdentityState)>) { let Some((did, state)) = removed else { return; }; @@ -210,7 +195,7 @@ let removed = self.by_did.remove_if_sync(&displaced_did, |state| { state.doc().is_some_and(|doc| doc.handle == handle) }); - self.remove_by_handle_for_removed(removed); + self.remove_by_handle_for_removed_did(removed); } if self.by_did_matches_handle(&did, &handle) { @@ -264,13 +249,23 @@ } } - /// Resolve a DID using Hydrant's partial identity data when available. - pub async fn resolve_by_did( + fn get_cached( &self, - did: &Did, - ) -> Result { - self.resolve_with_cache(&AtIdentifier::Did(did.clone()), false) - .await + identifier: &AtIdentifier, + require_fetched: bool, + ) -> Result, IdentityResolveError> { + let result = self.cached(identifier, require_fetched); + match &result { + Ok(Some(_)) | Err(_) => self.stats.hits.fetch_add(1, Ordering::Relaxed), + Ok(None) => self.stats.misses.fetch_add(1, Ordering::Relaxed), + }; + result + } + + /// Get a Hydrant-observed DID without waiting on Slingshot. + pub fn get_by_did(&self, did: &Did) -> Result { + self.get_cached(&AtIdentifier::Did(did.clone()), false)? + .ok_or(IdentityResolveError::NotFound) } /// Resolve a minidoc, fetching Hydrant-only observations upstream first. @@ -286,17 +281,9 @@ identifier: &AtIdentifier, require_fetched: bool, ) -> Result { - match self.cached(identifier, require_fetched) { - Ok(Some(doc)) => { - self.stats.hits.fetch_add(1, Ordering::Relaxed); - return Ok(doc); - } - Err(error) => { - self.stats.hits.fetch_add(1, Ordering::Relaxed); - return Err(error); - } - Ok(None) => self.stats.misses.fetch_add(1, Ordering::Relaxed), - }; + if let Some(doc) = self.get_cached(identifier, require_fetched)? { + return Ok(doc); + } let key = identifier.as_str().to_owned(); let cell = self @@ -335,10 +322,9 @@ fn insert_fetched_by_did(&self, doc: MiniDoc) -> Result { let did = doc.did.clone(); let handle = doc.handle.clone(); - let mut evicted = None; let mut previous_handle = None; let stored = match self.by_did.entry_sync(did.clone()) { - CacheEntry::Occupied(mut occupied) => match occupied.get_mut() { + MapEntry::Occupied(mut occupied) => match occupied.get_mut() { IdentityState::Inactive => return Err(IdentityResolveError::NotFound), IdentityState::Observed(observed) if observed.handle != handle => { observed.pds = doc.pds; @@ -346,19 +332,16 @@ } IdentityState::Observed(previous) | IdentityState::Fetched(previous) => { previous_handle = Some(previous.handle.clone()); - occupied.put(IdentityState::Fetched(doc.clone())); + occupied.insert(IdentityState::Fetched(doc.clone())); doc } }, - CacheEntry::Vacant(vacant) => { - let (removed, occupied) = vacant.put_entry(IdentityState::Fetched(doc.clone())); - evicted = removed; - drop(occupied); + MapEntry::Vacant(vacant) => { + vacant.insert_entry(IdentityState::Fetched(doc.clone())); doc } }; self.remove_by_handle_if_owned(&did, previous_handle.as_ref()); - self.remove_by_handle_for_removed(evicted); self.insert_by_handle(did, handle); Ok(stored) } @@ -371,14 +354,12 @@ use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; - const TEST_CAPACITY: usize = 64; - fn hasher() -> RuntimeHasher { RuntimeHasher::from_seeds(1, 2, 3, 4) } fn resolver() -> IdentityResolver { - IdentityResolver::detached(hasher(), TEST_CAPACITY) + IdentityResolver::detached(hasher()) } fn did(value: &str) -> Did { @@ -389,19 +370,19 @@ Handle::new_owned(value).unwrap() } - #[tokio::test] - async fn observed_identity_resolves_by_did_without_upstream() { + #[test] + fn observed_identity_resolves_by_did_without_upstream() { let resolver = resolver(); resolver.observe(did("did:plc:dawn"), handle("ptr.pet")); - let doc = resolver.resolve_by_did(&did("did:plc:dawn")).await.unwrap(); + let doc = resolver.get_by_did(&did("did:plc:dawn")).unwrap(); assert_eq!(doc.handle, handle("ptr.pet")); assert_eq!(doc.pds, None); assert_eq!(resolver.stats().hits, 1); } - #[tokio::test] - async fn observed_identity_updates_existing_did_and_removes_old_handle() { + #[test] + fn observed_identity_updates_existing_did_and_removes_old_handle() { let resolver = resolver(); let identity = did("did:plc:dawn"); resolver.observe(identity.clone(), handle("ptr.pet")); @@ -413,12 +394,12 @@ .unwrap() .is_none() ); - let updated = resolver.resolve_by_did(&identity).await.unwrap(); + let updated = resolver.get_by_did(&identity).unwrap(); assert_eq!(updated.handle, handle("new.ptr.pet")); } - #[tokio::test] - async fn handle_reassignment_keeps_the_new_owner() { + #[test] + fn handle_reassignment_keeps_the_new_owner() { let resolver = resolver(); let first = did("did:plc:first"); let second = did("did:plc:second"); @@ -491,7 +472,7 @@ .await; let client = SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(); - let resolver = IdentityResolver::with_slingshot(client, hasher(), TEST_CAPACITY); + let resolver = IdentityResolver::with_slingshot(client, hasher()); let identity = did("did:plc:dawn"); resolver.observe(identity.clone(), handle("ptr.pet")); @@ -510,15 +491,15 @@ assert_eq!(resolver.stats().upstream_requests, 1); } - #[tokio::test] - async fn inactive_identity_rejects_an_in_flight_result() { + #[test] + fn inactive_identity_rejects_an_in_flight_result() { let resolver = resolver(); let identity = did("did:plc:dawn"); resolver.observe(identity.clone(), handle("ptr.pet")); resolver.deactivate(identity.clone()); assert_eq!( - resolver.resolve_by_did(&identity).await, + resolver.get_by_did(&identity), Err(IdentityResolveError::NotFound) ); assert_eq!( @@ -535,20 +516,5 @@ .unwrap() .is_none() ); - } - - #[test] - fn cache_capacity_bounds_forward_and_reverse_indexes() { - let resolver = resolver(); - for n in 0..512 { - resolver.observe( - did(&format!("did:plc:user{n}")), - handle(&format!("user{n}.example.com")), - ); - } - - let stats = resolver.stats(); - assert!(stats.entries <= stats.capacity); - assert!(resolver.by_handle.len() <= stats.capacity); } } diff --git a/bobbin/crates/resolver/src/lib.rs b/bobbin/crates/resolver/src/lib.rs --- a/bobbin/crates/resolver/src/lib.rs +++ b/bobbin/crates/resolver/src/lib.rs @@ -3,8 +3,7 @@ mod normalize; pub use identity::{ - DEFAULT_IDENTITY_CACHE_ENTRIES, IdentityResolveError, IdentityResolver, - IdentityResolverStatsSnapshot, MiniDoc, + IdentityResolveError, IdentityResolver, IdentityResolverStatsSnapshot, MiniDoc, }; pub use legacy_upgrade::{ DecodedRecord, decode_canon_or_upgrade, decode_canon_or_upgrade_bytes, normalize_record_fields, diff --git a/bobbin/crates/xrpc/src/enrich.rs b/bobbin/crates/xrpc/src/enrich.rs --- a/bobbin/crates/xrpc/src/enrich.rs +++ b/bobbin/crates/xrpc/src/enrich.rs @@ -8,7 +8,6 @@ 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; @@ -29,7 +28,6 @@ pub const TYPE_MINIDOC: &str = "com.bad-example.identity.miniDoc"; const KNOWN_TYPES: [&str; 4] = [TYPE_COUNT, TYPE_DISTINCT_AUTHORS, TYPE_VIEWER, TYPE_MINIDOC]; -const MINIDOC_CONCURRENCY: usize = 32; /// 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 @@ -300,7 +298,7 @@ } } - let docs = resolve_minidocs(&state, minidoc_targets).await; + let docs = cached_minidocs(&state, minidoc_targets); for (target, sources, doc) in docs { for source in sources { put( @@ -331,25 +329,21 @@ } } -/// we drop failures, the client falls back to resolveMiniDoc for misses -async fn resolve_minidocs( +fn cached_minidocs( state: &AppState, targets: HashMap, Vec>, ) -> Vec<(Did, Vec, Value)> { - futures::stream::iter(targets) - .map(|(did, sources)| async move { - let doc = state + targets + .into_iter() + .filter_map(|(did, sources)| { + state .identity - .resolve_by_did(&did) - .await + .get_by_did(&did) .ok() - .and_then(|doc| serde_json::to_value(doc).ok()); - (did, sources, doc) + .and_then(|doc| serde_json::to_value(doc).ok()) + .map(|doc| (did, sources, doc)) }) - .buffer_unordered(MINIDOC_CONCURRENCY) - .filter_map(|(did, sources, doc)| async move { doc.map(|doc| (did, sources, doc)) }) .collect() - .await } fn descriptor_error(source: &LinkSource, msg: &str) -> XrpcError { diff --git a/bobbin/crates/xrpc/src/feed.rs b/bobbin/crates/xrpc/src/feed.rs --- a/bobbin/crates/xrpc/src/feed.rs +++ b/bobbin/crates/xrpc/src/feed.rs @@ -286,7 +286,7 @@ viewer: Option<&Did>, did: &Did, ) -> ProfileViewBasic { - let handle = resolve_handle(state, did).await; + let handle = resolve_handle(state, did); let avatar = None; // state.avatar.as_ref().and_then(|s| s.url(did)); let viewer_state = build_actor_viewer_state(&state.edges, viewer, did); ProfileViewBasic::new() @@ -302,7 +302,7 @@ viewer: Option<&Did>, did: &Did, ) -> ProfileViewDetailed { - let handle = resolve_handle(state, did).await; + let handle = resolve_handle(state, did); let avatar = None; // state.avatar.as_ref().and_then(|s| s.url(did)); let viewer_state = build_actor_viewer_state(&state.edges, viewer, did); let followers = state.edges.count(&EdgeKey::new( @@ -347,11 +347,10 @@ }) } -async fn resolve_handle(state: &AppState, did: &Did) -> Handle { +fn resolve_handle(state: &AppState, did: &Did) -> Handle { state .identity - .resolve_by_did(did) - .await + .get_by_did(did) .ok() .map(|doc| doc.handle) .unwrap_or_else(|| { diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -30,9 +30,7 @@ KnotHost, KnotProxy, KnotProxyError, MirrorNsid, MirrorProxy, ProxyResponse, RepoSlug, }; use bobbin_record_lru::RecordStore; -use bobbin_resolver::{ - DEFAULT_IDENTITY_CACHE_ENTRIES, IdentityResolveError, IdentityResolver, RepoIdResolver, -}; +use bobbin_resolver::{IdentityResolveError, IdentityResolver, RepoIdResolver}; use bobbin_runtime::ReqwestHttp; use bobbin_search::{ SearchCursor, SearchError, SearchFilters, SearchHit, SearchOffset, SearchReader, @@ -163,7 +161,6 @@ let identity = Arc::new(IdentityResolver::with_slingshot( slingshot.clone(), bobbin_runtime::RuntimeHasher::default(), - DEFAULT_IDENTITY_CACHE_ENTRIES, )); Self { records, diff --git a/bobbin/crates/xrpc/tests/enrich.rs b/bobbin/crates/xrpc/tests/enrich.rs --- a/bobbin/crates/xrpc/tests/enrich.rs +++ b/bobbin/crates/xrpc/tests/enrich.rs @@ -15,7 +15,7 @@ use jacquard_common::DefaultStr; use jacquard_common::types::did::Did; use jacquard_common::types::nsid::Nsid; -use jacquard_common::types::string::AtUri; +use jacquard_common::types::string::{AtUri, Handle}; use serde_json::{Value, json}; use tower::ServiceExt; use url::Url; @@ -35,6 +35,10 @@ fn did(s: &str) -> Did { Did::new_owned(s).unwrap() +} + +fn handle(s: &str) -> Handle { + Handle::new_owned(s).unwrap() } fn nsid(s: &'static str) -> Nsid { @@ -542,7 +546,7 @@ } #[tokio::test] -async fn minidoc_payloads_resolve_record_authors() { +async fn minidoc_payloads_use_observed_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() { @@ -559,24 +563,9 @@ ) .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" - }))) - .expect(1) - .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)) - .expect(1) - .mount(&h.server) - .await; + h.state + .identity + .observe(did("did:plc:a"), handle("a.example.com")); let app = router(h.state.clone()); let (status, body) = json_response( @@ -603,10 +592,11 @@ body["data"]["did:plc:a"]["sh.tangled.feed.star:.repo"][MINIDOC]["handle"], json!("a.example.com") ); - // resolution failures are dropped, the client falls back for misses + // missing observations are dropped without waiting on Slingshot 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}"); + assert_eq!(h.state.identity.stats().upstream_requests, 0); } #[tokio::test] @@ -615,15 +605,9 @@ 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; + h.state + .identity + .observe(owner.clone(), handle("nel.example.com")); let app = router(h.state.clone()); // sh.tangled.repo has no author mirror; stats would 400, minidocs must not diff --git a/web/src/lib/api/enrich.ts b/web/src/lib/api/enrich.ts --- a/web/src/lib/api/enrich.ts +++ b/web/src/lib/api/enrich.ts @@ -1,6 +1,6 @@ import type { BobbinContext, XrpcRequestInit } from "./client"; import type { Nsid } from "@atcute/lexicons/syntax"; -import type { MiniDoc } from "./identity"; +import { INVALID_HANDLE, type MiniDoc } from "./identity"; import { jsonPost } from "./_request"; // payload types, also the keys payloads land under in the data sidecar @@ -64,3 +64,6 @@ source: LinkSource ): MiniDoc | undefined => did !== undefined ? (data[did]?.[source]?.[TYPE_MINIDOC] as MiniDoc | undefined) : undefined; + +export const handleOf = (data: Sidecar, did: string | undefined, source: LinkSource): string => + miniDocOf(data, did, source)?.handle ?? INVALID_HANDLE; diff --git a/web/src/lib/api/identity.ts b/web/src/lib/api/identity.ts --- a/web/src/lib/api/identity.ts +++ b/web/src/lib/api/identity.ts @@ -7,6 +7,8 @@ pds?: string; } +export const INVALID_HANDLE = "handle.invalid"; + export const resolveMiniDoc = ( ctx: BobbinContext, identifier: string, diff --git a/bobbin/crates/bobbin/src/mem/report.rs b/bobbin/crates/bobbin/src/mem/report.rs --- a/bobbin/crates/bobbin/src/mem/report.rs +++ b/bobbin/crates/bobbin/src/mem/report.rs @@ -87,7 +87,6 @@ #[derive(Serialize)] struct Identity { entries: usize, - capacity: usize, hits: u64, misses: u64, upstream_requests: u64, @@ -178,7 +177,6 @@ }, identity: Identity { entries: identity.entries, - capacity: identity.capacity, hits: identity.hits, misses: identity.misses, upstream_requests: identity.upstream_requests, diff --git a/web/src/lib/components/profile/pages.test.ts b/web/src/lib/components/profile/pages.test.ts --- a/web/src/lib/components/profile/pages.test.ts +++ b/web/src/lib/components/profile/pages.test.ts @@ -111,6 +111,31 @@ ); }); + it("does not retry missing enriched identities", async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + output: { + items: [ + { + uri: "at://did:plc:bob/sh.tangled.graph.follow/one", + value: { subject: "did:plc:alice", createdAt: "2026-08-01T00:00:00Z" } + } + ] + }, + data: {} + }) + ); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); + + const page = await fetchPeoplePage(ctx, { + did: "did:plc:alice", + direction: "followers" + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(page.items[0].handle).toBe("handle.invalid"); + }); + it("targets incoming vouch authors", async () => { const fetchMock = vi.fn().mockResolvedValue(enriched()); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -1,14 +1,14 @@ // page fetchers for the profile tabs, shared between the route load (first // page) and the tab components (pagination). identities come from the enrich -// sidecar's minidoc payloads, resolveMiniDoc only fires for sidecar misses +// sidecar's minidoc payloads; misses render as handle.invalid without retrying import type { BobbinContext } from "$lib/api/client"; import type { Did } from "@atcute/lexicons/syntax"; import { enrich, countOf, + handleOf, viewerUriOf, - miniDocOf, TYPE_COUNT, TYPE_VIEWER, TYPE_MINIDOC, @@ -17,7 +17,7 @@ type LinkSource } from "$lib/api/enrich"; import { fetchPage } from "$lib/api/pagination"; -import { IdentityCache, type MiniDoc } from "$lib/api/identity"; +import { IdentityCache, INVALID_HANDLE } 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"; @@ -106,16 +106,6 @@ 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 { @@ -128,42 +118,21 @@ }; }; -// the data sidecar already carries follower counts and viewer status, the -// only extra cost is one miniDoc per did -const resolvePeople = async ( +const resolvePeople = ( dids: string[], data: Sidecar, docSource: LinkSource, - cache: IdentityCache, viewerDid?: string -): Promise => { - const unique = [...new Set(dids)]; - - 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]; +): PersonData[] => { + return [...new Set(dids)].map((did) => { + const handle = handleOf(data, did, docSource); 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(data, did, FOLLOW_VIEWER.source); const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; - byDid.set( - did, - doc - ? { - did: doc.did, - handle: doc.handle, - followers, - following, - isSelf, - viewerFollowRkey - } - : { did, handle: did, followers, following, isSelf, viewerFollowRkey } - ); + return { did, handle, followers, following, isSelf, viewerFollowRkey }; }); - return unique.map((did) => byDid.get(did) as PersonData); }; const resolveVouches = async ( @@ -178,14 +147,14 @@ const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); // 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)); + const handle = + direction === "incoming" && data + ? handleOf(data, otherDid, VOUCHER_DOCS.source) + : ((await cache.resolve(otherDid).catch(() => null))?.handle ?? INVALID_HANDLE); return { uri: item.uri, did: otherDid, - handle: doc?.handle ?? otherDid, + handle, kind: value.kind === "denounce" ? "denounce" : "vouch", direction, reason: value.reason, @@ -199,7 +168,6 @@ ctx: BobbinContext, starData: Sidecar, items: ListItem[], - cache: IdentityCache, viewerDid?: string ): Promise => { const repoDids = [ @@ -224,36 +192,34 @@ const reposByDid = new Map( enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) ); - const resolved = await Promise.all( - items.map(async (item): Promise => { - const value = item.value as ShTangledFeedStar.Main; - const subject = value.subject; - if (subject && "did" in subject && subject.did) { - const repo = reposByDid.get(subject.did); - if (!repo) return null; - const ownerDid = didFromUri(repo.uri); - 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.data) - }; - } - if (subject && "uri" in subject && subject.uri) { - const ownerDid = didFromUri(subject.uri); - const owner = await docOrResolve(starData, STAR_SUBJECT_DOCS.source, cache, ownerDid); - return { - kind: "string", - uri: item.uri, - createdAt: value.createdAt, - ownerHandle: owner?.handle ?? ownerDid, - rkey: rkeyFromUri(subject.uri) - }; - } - return null; - }) - ); + const resolved = items.map((item): StarData | null => { + const value = item.value as ShTangledFeedStar.Main; + const subject = value.subject; + if (subject && "did" in subject && subject.did) { + const repo = reposByDid.get(subject.did); + if (!repo) return null; + const ownerDid = didFromUri(repo.uri); + const ownerHandle = handleOf(enriched.data, ownerDid, REPO_OWNER_DOCS.source); + return { + kind: "repo", + uri: item.uri, + createdAt: value.createdAt, + repo: resolveRepoCard(repo, ownerHandle, enriched.data) + }; + } + if (subject && "uri" in subject && subject.uri) { + const ownerDid = didFromUri(subject.uri); + const ownerHandle = handleOf(starData, ownerDid, STAR_SUBJECT_DOCS.source); + return { + kind: "string", + uri: item.uri, + createdAt: value.createdAt, + ownerHandle, + rkey: rkeyFromUri(subject.uri) + }; + } + return null; + }); return resolved.filter((star): star is StarData => star !== null); }; @@ -323,13 +289,12 @@ did: string; viewerDid?: string; cursor?: string; - cache?: IdentityCache; limit?: number; } export const fetchStarredPage = async ( ctx: BobbinContext, - { did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions + { did, viewerDid, cursor, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions ): Promise> => { const page = await enrich>(ctx, { xrpc: "sh.tangled.feed.listStarsBy", @@ -337,13 +302,7 @@ enrich: [target(STAR_SUBJECT_DOCS, ["items[].value.subject.uri"])] }); return { - items: await resolveStars( - ctx, - page.data, - page.output.items, - cache ?? new IdentityCache(ctx), - viewerDid - ), + items: await resolveStars(ctx, page.data, page.output.items, viewerDid), cursor: page.output.cursor }; }; @@ -353,13 +312,12 @@ viewerDid?: string; direction: "followers" | "following"; cursor?: string; - cache?: IdentityCache; limit?: number; } export const fetchPeoplePage = async ( ctx: BobbinContext, - { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions + { did, viewerDid, direction, cursor, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions ): Promise> => { const docs = direction === "followers" ? FOLLOWER_DOCS : FOLLOWING_DOCS; const targets = direction === "followers" ? ["items[].uri"] : ["items[].value.subject"]; @@ -378,13 +336,7 @@ ? 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.data, - docs.source, - cache ?? new IdentityCache(ctx), - viewerDid - ), + items: resolvePeople(dids, enriched.data, docs.source, viewerDid), cursor: enriched.output.cursor }; }; diff --git a/web/src/routes/[handle]/[repo]/+layout.ts b/web/src/routes/[handle]/[repo]/+layout.ts --- a/web/src/routes/[handle]/[repo]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/+layout.ts @@ -1,11 +1,12 @@ import { error, redirect } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; +import { enrich, handleOf, TYPE_MINIDOC } from "$lib/api/enrich"; import { gitTarget, resolveDefaultBranch } from "$lib/api/gitclient"; import { getStarRkey } from "$lib/api/graph"; -import { IdentityCache, resolveMiniDoc } from "$lib/api/identity"; +import { resolveMiniDoc } from "$lib/api/identity"; import { parallel, toHttpError } from "$lib/api/load"; -import { getRepo } from "$lib/api/records"; +import type { RecordView, RepoRecord } from "$lib/api/records"; import { repoNameOf, resolveRepoByName } from "$lib/api/repo"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; import type { BobbinContext } from "$lib/api/client"; @@ -20,10 +21,17 @@ ): Promise => { if (!uri?.startsWith("at://")) return null; try { - const view = await getRepo(ctx, uri); + const page = await enrich>(ctx, { + xrpc: "sh.tangled.repo.getRepo", + params: { repo: uri }, + enrich: [{ source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC, targets: ["uri"] }] + }); + const view = page.output; const ownerDid = didFromUri(view.uri); - const owner = await new IdentityCache(ctx).resolve(ownerDid).catch(() => null); - return { ownerHandle: owner?.handle ?? ownerDid, name: repoNameOf(view) }; + return { + ownerHandle: handleOf(page.data, ownerDid, "sh.tangled.repo:.repo"), + name: repoNameOf(view) + }; } catch { // a fork whose source is gone still renders, just without the attribution return null; diff --git a/web/src/lib/components/profile/tabs/PeopleTab.svelte b/web/src/lib/components/profile/tabs/PeopleTab.svelte --- a/web/src/lib/components/profile/tabs/PeopleTab.svelte +++ b/web/src/lib/components/profile/tabs/PeopleTab.svelte @@ -2,7 +2,6 @@ import { untrack } from "svelte"; import { getAuth } from "$lib/auth.svelte"; import { createBobbinClient } from "$lib/api/client"; - import { IdentityCache } from "$lib/api/identity"; import FollowCard from "../FollowCard.svelte"; import Section from "$lib/components/ui/Section.svelte"; import Pagination from "$lib/components/ui/Pagination.svelte"; @@ -33,19 +32,15 @@ const auth = getAuth(); - // shared across pages so repeat dids only resolve once - let identityCache: IdentityCache | undefined; const pager = createCursorPager( { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, (cursor) => { const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); - identityCache ??= new IdentityCache(ctx); return fetchPeoplePage(ctx, { did, viewerDid: auth.currentDid ?? undefined, direction, - cursor, - cache: identityCache + cursor }); } ); diff --git a/web/src/lib/components/profile/tabs/StarredTab.svelte b/web/src/lib/components/profile/tabs/StarredTab.svelte --- a/web/src/lib/components/profile/tabs/StarredTab.svelte +++ b/web/src/lib/components/profile/tabs/StarredTab.svelte @@ -3,7 +3,6 @@ import { resolve } from "$app/paths"; import { getAuth } from "$lib/auth.svelte"; import { createBobbinClient } from "$lib/api/client"; - import { IdentityCache } from "$lib/api/identity"; import RepoCard from "$lib/components/repo/RepoCard.svelte"; import Card from "$lib/components/ui/Card.svelte"; import Section from "$lib/components/ui/Section.svelte"; @@ -24,18 +23,14 @@ const auth = getAuth(); - // shared across pages so repeat repo owners only resolve once - let identityCache: IdentityCache | undefined; const pager = createCursorPager( { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, (cursor) => { const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); - identityCache ??= new IdentityCache(ctx); return fetchStarredPage(ctx, { did, viewerDid: auth.currentDid ?? undefined, - cursor, - cache: identityCache + cursor }); } ); diff --git a/web/src/routes/[handle]/[repo]/issues/+page.ts b/web/src/routes/[handle]/[repo]/issues/+page.ts --- a/web/src/routes/[handle]/[repo]/issues/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/+page.ts @@ -1,7 +1,6 @@ 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 { enrich, handleOf, TYPE_MINIDOC } from "$lib/api/enrich"; import type { IssueListPage } from "$lib/api/records"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; import type { IssueSummary } from "$lib/components/repo/types"; @@ -40,25 +39,20 @@ count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }).catch(() => null) ]); - const identity = new IdentityCache(ctx); - const issues: IssueSummary[] = await Promise.all( - page.output.items.map(async (item): Promise => { - const authorDid = didFromUri(item.uri); - 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), - title: item.value.title, - state: item.state === "closed" ? "closed" : "open", - authorDid, - authorHandle: author?.handle ?? authorDid, - createdAt: item.value.createdAt, - commentCount: item.commentCount - }; - }) - ); + const issues: IssueSummary[] = page.output.items.map((item): IssueSummary => { + const authorDid = didFromUri(item.uri); + const authorHandle = handleOf(page.data, authorDid, "sh.tangled.repo.issue:.repo"); + return { + uri: item.uri, + rkey: rkeyFromUri(item.uri), + title: item.value.title, + state: item.state === "closed" ? "closed" : "open", + authorDid, + authorHandle, + createdAt: item.value.createdAt, + commentCount: item.commentCount + }; + }); return { state: state as "open" | "closed", diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts @@ -1,7 +1,7 @@ import { error } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; -import { enrich, miniDocOf, TYPE_MINIDOC, type LinkSource, type Sidecar } from "$lib/api/enrich"; -import { IdentityCache, type MiniDoc } from "$lib/api/identity"; +import { enrich, handleOf, miniDocOf, TYPE_MINIDOC } from "$lib/api/enrich"; +import { INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; import { listIssueStates, type CommentListPage, @@ -33,23 +33,14 @@ 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, host: event.url.host }; - const [author, states, comments] = await Promise.all([ - docOrResolve(issuePage.data, "sh.tangled.repo.issue:.repo", authorDid), + const authorHandle = handleOf(issuePage.data, authorDid, "sh.tangled.repo.issue:.repo"); + const [states, comments] = await Promise.all([ listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null), enrich(ctx, { xrpc: "sh.tangled.feed.listComments", @@ -108,35 +99,33 @@ } return undefined; }; - const reactorHandles = new Map(); - await Promise.all( - [...reactorDids].map(async (did) => { - const doc = reactorDoc(did) ?? (await identities.resolve(did).catch(() => null)); - reactorHandles.set(did, doc?.handle ?? did); - }) + const reactorHandles = new Map( + [...reactorDids].map((did) => [did, reactorDoc(did)?.handle ?? INVALID_HANDLE] as const) ); const reactionsFor = (subject: string): ReactionGroup[] => buildReactions( reactionsBySubject.get(subject) ?? [], viewerDid, - (did) => reactorHandles.get(did) ?? did + (did) => reactorHandles.get(did) ?? INVALID_HANDLE ); const threadInputs: ThreadInput[] = await Promise.all( commentItems.map(async (item): Promise => { const commentDid = didFromUri(item.uri); const commentBody = item.value.body?.text ?? ""; - const [doc, commentBodyHtml] = await Promise.all([ - docOrResolve(comments?.data ?? {}, "sh.tangled.feed.comment:.repo", commentDid), - commentBody ? renderMarkup(commentBody, markupOpts) : Promise.resolve(null) - ]); + const authorHandle = handleOf( + comments?.data ?? {}, + commentDid, + "sh.tangled.feed.comment:.repo" + ); + const commentBodyHtml = commentBody ? await renderMarkup(commentBody, markupOpts) : null; return { comment: { uri: item.uri, cid: item.cid, rkey: rkeyFromUri(item.uri), authorDid: commentDid, - authorHandle: doc?.handle ?? commentDid, + authorHandle, createdAt: item.value.createdAt, body: commentBody, bodyHtml: commentBodyHtml, @@ -158,7 +147,7 @@ bodyHtml, state, authorDid, - authorHandle: author?.handle ?? authorDid, + authorHandle, createdAt: record.value.createdAt, reactions: reactionsFor(record.uri) }, -- tangled.sh