This repository has no description
0

Configure Feed

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

bobbin/crates/{bobbin,ingest,resolver,xrpc},web: only use indexed did docs for enriching things, remove cache limit

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

author
dawn
date (Aug 4, 2026, 5:18 PM +0300) commit fecfe8e2 parent 6c182913 change-id zlquzwqw
+216 -354
+1
Cargo.lock
··· 857 857 "bobbin-edge-index", 858 858 "bobbin-ingest", 859 859 "bobbin-record-lru", 860 + "bobbin-resolver", 860 861 "bobbin-runtime", 861 862 "bobbin-search", 862 863 "bobbin-slingshot-client",
+1
bobbin/crates/bobbin-sim/Cargo.toml
··· 16 16 bobbin-edge-index = { workspace = true } 17 17 bobbin-ingest = { workspace = true } 18 18 bobbin-record-lru = { workspace = true } 19 + bobbin-resolver = { workspace = true } 19 20 bobbin-runtime = { workspace = true } 20 21 bobbin-search = { workspace = true } 21 22 bobbin-slingshot-client = { workspace = true }
+1 -4
bobbin/crates/bobbin-sim/src/runtime.rs
··· 116 116 search: Arc::new(NoopSearchSink), 117 117 records: records.clone() as Arc<dyn bobbin_record_lru::RecordStore>, 118 118 resolver: resolver.clone(), 119 - identity: Arc::new(IdentityResolver::detached( 120 - hasher.clone(), 121 - bobbin_resolver::DEFAULT_IDENTITY_CACHE_ENTRIES, 122 - )), 119 + identity: Arc::new(IdentityResolver::detached(hasher.clone())), 123 120 clock: clock.clone(), 124 121 entropy: entropy.clone(), 125 122 ws: mem_ws,
-12
bobbin/crates/bobbin/src/config.rs
··· 26 26 "backpressure.reserved_index_bytes", 27 27 "slingshot.url", 28 28 "record_cache.lru_bytes", 29 - "identity_cache.max_entries", 30 29 "search.heap_bytes", 31 30 "knot.allow_private", 32 31 "knot.require_https", ··· 50 49 "BOBBIN_BACKPRESSURE_RESERVED_INDEX_BYTES", 51 50 "BOBBIN_SLINGSHOT_URL", 52 51 "BOBBIN_RECORD_LRU_BYTES", 53 - "BOBBIN_IDENTITY_CACHE_ENTRIES", 54 52 "BOBBIN_SEARCH_HEAP_BYTES", 55 53 "BOBBIN_KNOT_ALLOW_PRIVATE", 56 54 "BOBBIN_KNOT_REQUIRE_HTTPS", ··· 77 75 78 76 #[config(nested)] 79 77 pub record_cache: RecordCacheConfig, 80 - 81 - #[config(nested)] 82 - pub identity_cache: IdentityCacheConfig, 83 78 84 79 #[config(nested)] 85 80 pub search: SearchConfig, ··· 234 229 /// LRU policy keyed on URI plus payload length. 235 230 #[config(env = "BOBBIN_RECORD_LRU_BYTES", default = 67_108_864)] 236 231 pub lru_bytes: u64, 237 - } 238 - 239 - #[derive(Debug, Config)] 240 - pub struct IdentityCacheConfig { 241 - /// Maximum number of (mini) DID doc entries that the identity cache will hold. 242 - #[config(env = "BOBBIN_IDENTITY_CACHE_ENTRIES", default = 100_000)] 243 - pub max_entries: usize, 244 232 } 245 233 246 234 #[derive(Debug, Config)]
-1
bobbin/crates/bobbin/src/main.rs
··· 190 190 let identity = Arc::new(IdentityResolver::with_slingshot( 191 191 slingshot.clone(), 192 192 hasher.clone(), 193 - cfg.identity_cache.max_entries, 194 193 )); 195 194 let mut resolver_opts = ResolverOptions::default(); 196 195 // NOTE: see https://tangled.org/nonbinary.computer/jacquard/issues/39.
-2
bobbin/crates/bobbin/src/mem/report.rs
··· 87 87 #[derive(Serialize)] 88 88 struct Identity { 89 89 entries: usize, 90 - capacity: usize, 91 90 hits: u64, 92 91 misses: u64, 93 92 upstream_requests: u64, ··· 178 177 }, 179 178 identity: Identity { 180 179 entries: identity.entries, 181 - capacity: identity.capacity, 182 180 hits: identity.hits, 183 181 misses: identity.misses, 184 182 upstream_requests: identity.upstream_requests,
+1 -4
bobbin/crates/ingest/examples/smoke.rs
··· 40 40 search: Arc::new(NoopSearchSink), 41 41 records: Arc::new(NoopRecordStore) as Arc<dyn RecordStore>, 42 42 resolver: Arc::new(RepoIdResolver::detached(hasher.clone())), 43 - identity: Arc::new(IdentityResolver::detached( 44 - hasher, 45 - bobbin_resolver::DEFAULT_IDENTITY_CACHE_ENTRIES, 46 - )), 43 + identity: Arc::new(IdentityResolver::detached(hasher)), 47 44 clock: Arc::new(SystemClock::new()), 48 45 entropy: Arc::new(OsEntropy), 49 46 ws: TungsteniteWs::shared(),
+6 -19
bobbin/crates/ingest/src/lib.rs
··· 9 9 }; 10 10 use bobbin_knot_ingest::{CapabilityGate, KnotRegistry}; 11 11 use bobbin_record_lru::RecordStore; 12 - #[cfg(test)] 13 - use bobbin_resolver::DEFAULT_IDENTITY_CACHE_ENTRIES; 14 12 use bobbin_resolver::{ 15 13 IdentityResolver, NormalizeRepoRefs, decode_canon_or_upgrade_bytes, synthesize_created_at, 16 14 }; ··· 1479 1477 }; 1480 1478 let pending = prepare_frame(frame, &ctx, now).await; 1481 1479 let pending = resolve_pending(pending, &ctx).await; 1482 - let identity = 1483 - IdentityResolver::detached(RuntimeHasher::default(), DEFAULT_IDENTITY_CACHE_ENTRIES); 1480 + let identity = IdentityResolver::detached(RuntimeHasher::default()); 1484 1481 commit_pending( 1485 1482 pending, 1486 1483 store, ··· 2541 2538 #[tokio::test] 2542 2539 async fn identity_frames_update_identity_resolver_lifecycle() { 2543 2540 let (store, issue_states, pull_statuses, coverage, resolver) = fresh(); 2544 - let identity = 2545 - IdentityResolver::detached(RuntimeHasher::default(), DEFAULT_IDENTITY_CACHE_ENTRIES); 2541 + let identity = IdentityResolver::detached(RuntimeHasher::default()); 2546 2542 let records = NoopRecordStore; 2547 2543 let search = NoopSearchSink; 2548 2544 let ctx = PipelineCtx { ··· 2584 2580 .await; 2585 2581 2586 2582 let doc = identity 2587 - .resolve_by_did(&Did::new_static("did:plc:olaren").unwrap()) 2588 - .await 2583 + .get_by_did(&Did::new_static("did:plc:olaren").unwrap()) 2589 2584 .expect("identity frame should seed the resolver"); 2590 2585 assert_eq!(doc.handle.as_ref(), "olaren.dev"); 2591 2586 ··· 2615 2610 .await; 2616 2611 2617 2612 assert_eq!( 2618 - identity 2619 - .resolve_by_did(&Did::new_static("did:plc:olaren").unwrap()) 2620 - .await, 2613 + identity.get_by_did(&Did::new_static("did:plc:olaren").unwrap()), 2621 2614 Err(bobbin_resolver::IdentityResolveError::NotFound) 2622 2615 ); 2623 2616 } ··· 2677 2670 search: Arc::new(NoopSearchSink), 2678 2671 records: Arc::new(NoopRecordStore) as Arc<dyn RecordStore>, 2679 2672 resolver: Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), 2680 - identity: Arc::new(IdentityResolver::detached( 2681 - RuntimeHasher::default(), 2682 - DEFAULT_IDENTITY_CACHE_ENTRIES, 2683 - )), 2673 + identity: Arc::new(IdentityResolver::detached(RuntimeHasher::default())), 2684 2674 clock: Arc::new(SystemClock::new()), 2685 2675 entropy: Arc::new(OsEntropy), 2686 2676 ws: TungsteniteWs::shared(), ··· 3553 3543 search: Arc::new(NoopSearchSink), 3554 3544 records: capturing.clone() as Arc<dyn RecordStore>, 3555 3545 resolver, 3556 - identity: Arc::new(IdentityResolver::detached( 3557 - RuntimeHasher::default(), 3558 - DEFAULT_IDENTITY_CACHE_ENTRIES, 3559 - )), 3546 + identity: Arc::new(IdentityResolver::detached(RuntimeHasher::default())), 3560 3547 clock, 3561 3548 entropy: Arc::new(OsEntropy), 3562 3549 ws: TungsteniteWs::shared(),
+56 -90
bobbin/crates/resolver/src/identity.rs
··· 7 7 use jacquard_common::types::did::Did; 8 8 use jacquard_common::types::ident::AtIdentifier; 9 9 use jacquard_common::types::string::Handle; 10 - use scc::hash_cache::Entry as CacheEntry; 11 - use scc::{HashCache as SccCache, HashMap as SccMap}; 10 + use scc::HashMap as SccMap; 11 + use scc::hash_map::Entry as MapEntry; 12 12 use serde::{Deserialize, Serialize}; 13 13 use thiserror::Error; 14 14 use tokio::sync::OnceCell; 15 - 16 - pub const DEFAULT_IDENTITY_CACHE_ENTRIES: usize = 100_000; 17 15 18 16 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 19 17 #[serde(rename_all = "camelCase")] ··· 66 64 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 67 65 pub struct IdentityResolverStatsSnapshot { 68 66 pub entries: usize, 69 - pub capacity: usize, 70 67 pub hits: u64, 71 68 pub misses: u64, 72 69 pub upstream_requests: u64, ··· 79 76 upstream_requests: AtomicU64, 80 77 } 81 78 79 + // in the future this would spill to disk probably 82 80 pub struct IdentityResolver { 83 - by_did: SccCache<Did<DefaultStr>, IdentityState, RuntimeHasher>, 81 + by_did: SccMap<Did<DefaultStr>, IdentityState, RuntimeHasher>, 84 82 by_handle: SccMap<Handle<DefaultStr>, Did<DefaultStr>, RuntimeHasher>, 85 83 in_flight: SccMap<String, Arc<OnceCell<Result<MiniDoc, IdentityResolveError>>>, RuntimeHasher>, 86 84 slingshot: Option<SlingshotClient>, ··· 88 86 } 89 87 90 88 impl IdentityResolver { 91 - pub fn with_slingshot( 92 - slingshot: SlingshotClient, 93 - hasher: RuntimeHasher, 94 - capacity: usize, 95 - ) -> Self { 96 - Self::new(Some(slingshot), hasher, capacity) 89 + pub fn with_slingshot(slingshot: SlingshotClient, hasher: RuntimeHasher) -> Self { 90 + Self::new(Some(slingshot), hasher) 97 91 } 98 92 99 - pub fn detached(hasher: RuntimeHasher, capacity: usize) -> Self { 100 - Self::new(None, hasher, capacity) 93 + pub fn detached(hasher: RuntimeHasher) -> Self { 94 + Self::new(None, hasher) 101 95 } 102 96 103 - fn new(slingshot: Option<SlingshotClient>, hasher: RuntimeHasher, capacity: usize) -> Self { 97 + fn new(slingshot: Option<SlingshotClient>, hasher: RuntimeHasher) -> Self { 104 98 Self { 105 - by_did: SccCache::with_capacity_and_hasher(0, capacity, hasher.clone()), 99 + by_did: SccMap::with_hasher(hasher.clone()), 106 100 by_handle: SccMap::with_hasher(hasher.clone()), 107 101 in_flight: SccMap::with_hasher(hasher), 108 102 slingshot, ··· 113 107 pub fn stats(&self) -> IdentityResolverStatsSnapshot { 114 108 IdentityResolverStatsSnapshot { 115 109 entries: self.by_did.len(), 116 - capacity: *self.by_did.capacity_range().end(), 117 110 hits: self.stats.hits.load(Ordering::Relaxed), 118 111 misses: self.stats.misses.load(Ordering::Relaxed), 119 112 upstream_requests: self.stats.upstream_requests.load(Ordering::Relaxed), ··· 121 114 } 122 115 123 116 pub fn observe(&self, did: Did<DefaultStr>, handle: Handle<DefaultStr>) { 124 - let mut evicted = None; 125 117 let mut previous_handle = None; 126 118 match self.by_did.entry_sync(did.clone()) { 127 - CacheEntry::Occupied(mut occupied) => { 119 + MapEntry::Occupied(mut occupied) => { 128 120 let (pds, fetched) = match occupied.get() { 129 121 IdentityState::Observed(previous) => { 130 122 previous_handle = Some(previous.handle.clone()); ··· 141 133 handle: handle.clone(), 142 134 pds, 143 135 }; 144 - occupied.put(if fetched { 136 + occupied.insert(if fetched { 145 137 IdentityState::Fetched(doc) 146 138 } else { 147 139 IdentityState::Observed(doc) 148 140 }); 149 141 } 150 - CacheEntry::Vacant(vacant) => { 151 - let (removed, occupied) = vacant.put_entry(IdentityState::Observed(MiniDoc { 142 + MapEntry::Vacant(vacant) => { 143 + vacant.insert_entry(IdentityState::Observed(MiniDoc { 152 144 did: did.clone(), 153 145 handle: handle.clone(), 154 146 pds: None, 155 147 })); 156 - evicted = removed; 157 - drop(occupied); 158 148 } 159 149 } 160 150 self.remove_by_handle_if_owned(&did, previous_handle.as_ref()); 161 - self.remove_by_handle_for_removed(evicted); 162 151 self.insert_by_handle(did, handle); 163 152 } 164 153 165 154 pub fn deactivate(&self, did: Did<DefaultStr>) { 166 - let mut evicted = None; 167 155 let mut previous_handle = None; 168 156 match self.by_did.entry_sync(did.clone()) { 169 - CacheEntry::Occupied(mut occupied) => { 157 + MapEntry::Occupied(mut occupied) => { 170 158 previous_handle = occupied.get().doc().map(|doc| doc.handle.clone()); 171 - occupied.put(IdentityState::Inactive); 159 + occupied.insert(IdentityState::Inactive); 172 160 } 173 - CacheEntry::Vacant(vacant) => { 174 - let (removed, occupied) = vacant.put_entry(IdentityState::Inactive); 175 - evicted = removed; 176 - drop(occupied); 161 + MapEntry::Vacant(vacant) => { 162 + vacant.insert_entry(IdentityState::Inactive); 177 163 } 178 164 } 179 165 self.remove_by_handle_if_owned(&did, previous_handle.as_ref()); 180 - self.remove_by_handle_for_removed(evicted); 181 166 } 182 167 183 168 fn remove_by_handle_if_owned( ··· 191 176 self.by_handle.remove_if_sync(handle, |owner| owner == did); 192 177 } 193 178 194 - fn remove_by_handle_for_removed(&self, removed: Option<(Did<DefaultStr>, IdentityState)>) { 179 + fn remove_by_handle_for_removed_did(&self, removed: Option<(Did<DefaultStr>, IdentityState)>) { 195 180 let Some((did, state)) = removed else { 196 181 return; 197 182 }; ··· 210 195 let removed = self.by_did.remove_if_sync(&displaced_did, |state| { 211 196 state.doc().is_some_and(|doc| doc.handle == handle) 212 197 }); 213 - self.remove_by_handle_for_removed(removed); 198 + self.remove_by_handle_for_removed_did(removed); 214 199 } 215 200 216 201 if self.by_did_matches_handle(&did, &handle) { ··· 264 249 } 265 250 } 266 251 267 - /// Resolve a DID using Hydrant's partial identity data when available. 268 - pub async fn resolve_by_did( 252 + fn get_cached( 269 253 &self, 270 - did: &Did<DefaultStr>, 271 - ) -> Result<MiniDoc, IdentityResolveError> { 272 - self.resolve_with_cache(&AtIdentifier::Did(did.clone()), false) 273 - .await 254 + identifier: &AtIdentifier<DefaultStr>, 255 + require_fetched: bool, 256 + ) -> Result<Option<MiniDoc>, IdentityResolveError> { 257 + let result = self.cached(identifier, require_fetched); 258 + match &result { 259 + Ok(Some(_)) | Err(_) => self.stats.hits.fetch_add(1, Ordering::Relaxed), 260 + Ok(None) => self.stats.misses.fetch_add(1, Ordering::Relaxed), 261 + }; 262 + result 263 + } 264 + 265 + /// Get a Hydrant-observed DID without waiting on Slingshot. 266 + pub fn get_by_did(&self, did: &Did<DefaultStr>) -> Result<MiniDoc, IdentityResolveError> { 267 + self.get_cached(&AtIdentifier::Did(did.clone()), false)? 268 + .ok_or(IdentityResolveError::NotFound) 274 269 } 275 270 276 271 /// Resolve a minidoc, fetching Hydrant-only observations upstream first. ··· 286 281 identifier: &AtIdentifier<DefaultStr>, 287 282 require_fetched: bool, 288 283 ) -> Result<MiniDoc, IdentityResolveError> { 289 - match self.cached(identifier, require_fetched) { 290 - Ok(Some(doc)) => { 291 - self.stats.hits.fetch_add(1, Ordering::Relaxed); 292 - return Ok(doc); 293 - } 294 - Err(error) => { 295 - self.stats.hits.fetch_add(1, Ordering::Relaxed); 296 - return Err(error); 297 - } 298 - Ok(None) => self.stats.misses.fetch_add(1, Ordering::Relaxed), 299 - }; 284 + if let Some(doc) = self.get_cached(identifier, require_fetched)? { 285 + return Ok(doc); 286 + } 300 287 301 288 let key = identifier.as_str().to_owned(); 302 289 let cell = self ··· 335 322 fn insert_fetched_by_did(&self, doc: MiniDoc) -> Result<MiniDoc, IdentityResolveError> { 336 323 let did = doc.did.clone(); 337 324 let handle = doc.handle.clone(); 338 - let mut evicted = None; 339 325 let mut previous_handle = None; 340 326 let stored = match self.by_did.entry_sync(did.clone()) { 341 - CacheEntry::Occupied(mut occupied) => match occupied.get_mut() { 327 + MapEntry::Occupied(mut occupied) => match occupied.get_mut() { 342 328 IdentityState::Inactive => return Err(IdentityResolveError::NotFound), 343 329 IdentityState::Observed(observed) if observed.handle != handle => { 344 330 observed.pds = doc.pds; ··· 346 332 } 347 333 IdentityState::Observed(previous) | IdentityState::Fetched(previous) => { 348 334 previous_handle = Some(previous.handle.clone()); 349 - occupied.put(IdentityState::Fetched(doc.clone())); 335 + occupied.insert(IdentityState::Fetched(doc.clone())); 350 336 doc 351 337 } 352 338 }, 353 - CacheEntry::Vacant(vacant) => { 354 - let (removed, occupied) = vacant.put_entry(IdentityState::Fetched(doc.clone())); 355 - evicted = removed; 356 - drop(occupied); 339 + MapEntry::Vacant(vacant) => { 340 + vacant.insert_entry(IdentityState::Fetched(doc.clone())); 357 341 doc 358 342 } 359 343 }; 360 344 self.remove_by_handle_if_owned(&did, previous_handle.as_ref()); 361 - self.remove_by_handle_for_removed(evicted); 362 345 self.insert_by_handle(did, handle); 363 346 Ok(stored) 364 347 } ··· 371 354 use wiremock::matchers::{method, path, query_param}; 372 355 use wiremock::{Mock, MockServer, ResponseTemplate}; 373 356 374 - const TEST_CAPACITY: usize = 64; 375 - 376 357 fn hasher() -> RuntimeHasher { 377 358 RuntimeHasher::from_seeds(1, 2, 3, 4) 378 359 } 379 360 380 361 fn resolver() -> IdentityResolver { 381 - IdentityResolver::detached(hasher(), TEST_CAPACITY) 362 + IdentityResolver::detached(hasher()) 382 363 } 383 364 384 365 fn did(value: &str) -> Did<DefaultStr> { ··· 389 370 Handle::new_owned(value).unwrap() 390 371 } 391 372 392 - #[tokio::test] 393 - async fn observed_identity_resolves_by_did_without_upstream() { 373 + #[test] 374 + fn observed_identity_resolves_by_did_without_upstream() { 394 375 let resolver = resolver(); 395 376 resolver.observe(did("did:plc:dawn"), handle("ptr.pet")); 396 377 397 - let doc = resolver.resolve_by_did(&did("did:plc:dawn")).await.unwrap(); 378 + let doc = resolver.get_by_did(&did("did:plc:dawn")).unwrap(); 398 379 assert_eq!(doc.handle, handle("ptr.pet")); 399 380 assert_eq!(doc.pds, None); 400 381 assert_eq!(resolver.stats().hits, 1); 401 382 } 402 383 403 - #[tokio::test] 404 - async fn observed_identity_updates_existing_did_and_removes_old_handle() { 384 + #[test] 385 + fn observed_identity_updates_existing_did_and_removes_old_handle() { 405 386 let resolver = resolver(); 406 387 let identity = did("did:plc:dawn"); 407 388 resolver.observe(identity.clone(), handle("ptr.pet")); ··· 413 394 .unwrap() 414 395 .is_none() 415 396 ); 416 - let updated = resolver.resolve_by_did(&identity).await.unwrap(); 397 + let updated = resolver.get_by_did(&identity).unwrap(); 417 398 assert_eq!(updated.handle, handle("new.ptr.pet")); 418 399 } 419 400 420 - #[tokio::test] 421 - async fn handle_reassignment_keeps_the_new_owner() { 401 + #[test] 402 + fn handle_reassignment_keeps_the_new_owner() { 422 403 let resolver = resolver(); 423 404 let first = did("did:plc:first"); 424 405 let second = did("did:plc:second"); ··· 491 472 .await; 492 473 let client = 493 474 SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(); 494 - let resolver = IdentityResolver::with_slingshot(client, hasher(), TEST_CAPACITY); 475 + let resolver = IdentityResolver::with_slingshot(client, hasher()); 495 476 let identity = did("did:plc:dawn"); 496 477 resolver.observe(identity.clone(), handle("ptr.pet")); 497 478 ··· 510 491 assert_eq!(resolver.stats().upstream_requests, 1); 511 492 } 512 493 513 - #[tokio::test] 514 - async fn inactive_identity_rejects_an_in_flight_result() { 494 + #[test] 495 + fn inactive_identity_rejects_an_in_flight_result() { 515 496 let resolver = resolver(); 516 497 let identity = did("did:plc:dawn"); 517 498 resolver.observe(identity.clone(), handle("ptr.pet")); 518 499 resolver.deactivate(identity.clone()); 519 500 520 501 assert_eq!( 521 - resolver.resolve_by_did(&identity).await, 502 + resolver.get_by_did(&identity), 522 503 Err(IdentityResolveError::NotFound) 523 504 ); 524 505 assert_eq!( ··· 535 516 .unwrap() 536 517 .is_none() 537 518 ); 538 - } 539 - 540 - #[test] 541 - fn cache_capacity_bounds_forward_and_reverse_indexes() { 542 - let resolver = resolver(); 543 - for n in 0..512 { 544 - resolver.observe( 545 - did(&format!("did:plc:user{n}")), 546 - handle(&format!("user{n}.example.com")), 547 - ); 548 - } 549 - 550 - let stats = resolver.stats(); 551 - assert!(stats.entries <= stats.capacity); 552 - assert!(resolver.by_handle.len() <= stats.capacity); 553 519 } 554 520 }
+1 -2
bobbin/crates/resolver/src/lib.rs
··· 3 3 mod normalize; 4 4 5 5 pub use identity::{ 6 - DEFAULT_IDENTITY_CACHE_ENTRIES, IdentityResolveError, IdentityResolver, 7 - IdentityResolverStatsSnapshot, MiniDoc, 6 + IdentityResolveError, IdentityResolver, IdentityResolverStatsSnapshot, MiniDoc, 8 7 }; 9 8 pub use legacy_upgrade::{ 10 9 DecodedRecord, decode_canon_or_upgrade, decode_canon_or_upgrade_bytes, normalize_record_fields,
+9 -15
bobbin/crates/xrpc/src/enrich.rs
··· 8 8 response::Response, 9 9 }; 10 10 use bobbin_types::ids::{EdgeKey, SubjectRef, nsid_static}; 11 - use futures::StreamExt; 12 11 use jacquard_common::DefaultStr; 13 12 use jacquard_common::IntoStatic; 14 13 use jacquard_common::types::did::Did; ··· 29 28 pub const TYPE_MINIDOC: &str = "com.bad-example.identity.miniDoc"; 30 29 31 30 const KNOWN_TYPES: [&str; 4] = [TYPE_COUNT, TYPE_DISTINCT_AUTHORS, TYPE_VIEWER, TYPE_MINIDOC]; 32 - const MINIDOC_CONCURRENCY: usize = 32; 33 31 34 32 /// a payload type nsid, with an optional #fragment for lexicon defs. the raw 35 33 /// string is kept because it echoes into the data map as the payload key ··· 300 298 } 301 299 } 302 300 303 - let docs = resolve_minidocs(&state, minidoc_targets).await; 301 + let docs = cached_minidocs(&state, minidoc_targets); 304 302 for (target, sources, doc) in docs { 305 303 for source in sources { 306 304 put( ··· 331 329 } 332 330 } 333 331 334 - /// we drop failures, the client falls back to resolveMiniDoc for misses 335 - async fn resolve_minidocs( 332 + fn cached_minidocs( 336 333 state: &AppState, 337 334 targets: HashMap<Did<DefaultStr>, Vec<LinkSource>>, 338 335 ) -> Vec<(Did<DefaultStr>, Vec<LinkSource>, Value)> { 339 - futures::stream::iter(targets) 340 - .map(|(did, sources)| async move { 341 - let doc = state 336 + targets 337 + .into_iter() 338 + .filter_map(|(did, sources)| { 339 + state 342 340 .identity 343 - .resolve_by_did(&did) 344 - .await 341 + .get_by_did(&did) 345 342 .ok() 346 - .and_then(|doc| serde_json::to_value(doc).ok()); 347 - (did, sources, doc) 343 + .and_then(|doc| serde_json::to_value(doc).ok()) 344 + .map(|doc| (did, sources, doc)) 348 345 }) 349 - .buffer_unordered(MINIDOC_CONCURRENCY) 350 - .filter_map(|(did, sources, doc)| async move { doc.map(|doc| (did, sources, doc)) }) 351 346 .collect() 352 - .await 353 347 } 354 348 355 349 fn descriptor_error(source: &LinkSource, msg: &str) -> XrpcError {
+4 -5
bobbin/crates/xrpc/src/feed.rs
··· 286 286 viewer: Option<&Did<DefaultStr>>, 287 287 did: &Did<DefaultStr>, 288 288 ) -> ProfileViewBasic<DefaultStr> { 289 - let handle = resolve_handle(state, did).await; 289 + let handle = resolve_handle(state, did); 290 290 let avatar = None; // state.avatar.as_ref().and_then(|s| s.url(did)); 291 291 let viewer_state = build_actor_viewer_state(&state.edges, viewer, did); 292 292 ProfileViewBasic::new() ··· 302 302 viewer: Option<&Did<DefaultStr>>, 303 303 did: &Did<DefaultStr>, 304 304 ) -> ProfileViewDetailed<DefaultStr> { 305 - let handle = resolve_handle(state, did).await; 305 + let handle = resolve_handle(state, did); 306 306 let avatar = None; // state.avatar.as_ref().and_then(|s| s.url(did)); 307 307 let viewer_state = build_actor_viewer_state(&state.edges, viewer, did); 308 308 let followers = state.edges.count(&EdgeKey::new( ··· 347 347 }) 348 348 } 349 349 350 - async fn resolve_handle(state: &AppState, did: &Did<DefaultStr>) -> Handle<DefaultStr> { 350 + fn resolve_handle(state: &AppState, did: &Did<DefaultStr>) -> Handle<DefaultStr> { 351 351 state 352 352 .identity 353 - .resolve_by_did(did) 354 - .await 353 + .get_by_did(did) 355 354 .ok() 356 355 .map(|doc| doc.handle) 357 356 .unwrap_or_else(|| {
+1 -4
bobbin/crates/xrpc/src/lib.rs
··· 28 28 }; 29 29 use bobbin_knot_proxy::{KnotHost, KnotProxy, KnotProxyError, ProxyResponse, RepoSlug}; 30 30 use bobbin_record_lru::RecordStore; 31 - use bobbin_resolver::{ 32 - DEFAULT_IDENTITY_CACHE_ENTRIES, IdentityResolveError, IdentityResolver, RepoIdResolver, 33 - }; 31 + use bobbin_resolver::{IdentityResolveError, IdentityResolver, RepoIdResolver}; 34 32 use bobbin_runtime::ReqwestHttp; 35 33 use bobbin_search::{ 36 34 SearchCursor, SearchError, SearchFilters, SearchHit, SearchOffset, SearchReader, ··· 160 158 let identity = Arc::new(IdentityResolver::with_slingshot( 161 159 slingshot.clone(), 162 160 bobbin_runtime::RuntimeHasher::default(), 163 - DEFAULT_IDENTITY_CACHE_ENTRIES, 164 161 )); 165 162 Self { 166 163 records,
+14 -30
bobbin/crates/xrpc/tests/enrich.rs
··· 15 15 use jacquard_common::DefaultStr; 16 16 use jacquard_common::types::did::Did; 17 17 use jacquard_common::types::nsid::Nsid; 18 - use jacquard_common::types::string::AtUri; 18 + use jacquard_common::types::string::{AtUri, Handle}; 19 19 use serde_json::{Value, json}; 20 20 use tower::ServiceExt; 21 21 use url::Url; ··· 35 35 36 36 fn did(s: &str) -> Did<DefaultStr> { 37 37 Did::new_owned(s).unwrap() 38 + } 39 + 40 + fn handle(s: &str) -> Handle<DefaultStr> { 41 + Handle::new_owned(s).unwrap() 38 42 } 39 43 40 44 fn nsid(s: &'static str) -> Nsid<DefaultStr> { ··· 542 546 } 543 547 544 548 #[tokio::test] 545 - async fn minidoc_payloads_resolve_record_authors() { 549 + async fn minidoc_payloads_use_observed_record_authors() { 546 550 let h = Harness::new().await; 547 551 let owner = did("did:plc:nel"); 548 552 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() { ··· 559 563 ) 560 564 .await; 561 565 } 562 - Mock::given(method("GET")) 563 - .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 564 - .and(query_param("identifier", "did:plc:a")) 565 - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 566 - "did": "did:plc:a", 567 - "handle": "a.example.com", 568 - "pds": "https://pds.example.com" 569 - }))) 570 - .expect(1) 571 - .mount(&h.server) 572 - .await; 573 - Mock::given(method("GET")) 574 - .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 575 - .and(query_param("identifier", "did:plc:b")) 576 - .respond_with(ResponseTemplate::new(404)) 577 - .expect(1) 578 - .mount(&h.server) 579 - .await; 566 + h.state 567 + .identity 568 + .observe(did("did:plc:a"), handle("a.example.com")); 580 569 581 570 let app = router(h.state.clone()); 582 571 let (status, body) = json_response( ··· 603 592 body["data"]["did:plc:a"]["sh.tangled.feed.star:.repo"][MINIDOC]["handle"], 604 593 json!("a.example.com") 605 594 ); 606 - // resolution failures are dropped, the client falls back for misses 595 + // missing observations are dropped without waiting on Slingshot 607 596 assert!(body["data"]["did:plc:b"].is_null(), "{body}"); 608 597 // the profile owner authored nothing here, so it earns no minidoc 609 598 assert!(body["data"]["did:plc:nel"].is_null(), "{body}"); 599 + assert_eq!(h.state.identity.stats().upstream_requests, 0); 610 600 } 611 601 612 602 #[tokio::test] ··· 615 605 let owner = did("did:plc:nel"); 616 606 let repo_did = did("did:plc:limpet"); 617 607 repo_fixture(&h, &owner, &repo_did).await; 618 - Mock::given(method("GET")) 619 - .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 620 - .and(query_param("identifier", "did:plc:nel")) 621 - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 622 - "did": "did:plc:nel", 623 - "handle": "nel.example.com" 624 - }))) 625 - .mount(&h.server) 626 - .await; 608 + h.state 609 + .identity 610 + .observe(owner.clone(), handle("nel.example.com")); 627 611 628 612 let app = router(h.state.clone()); 629 613 // sh.tangled.repo has no author mirror; stats would 400, minidocs must not
-8
bobbin/example.toml
··· 135 135 # Default value: 67108864 136 136 #lru_bytes = 67108864 137 137 138 - [identity_cache] 139 - # Maximum number of DID entries retained by the in-process identity cache. 140 - # 141 - # Can also be specified via environment variable `BOBBIN_IDENTITY_CACHE_ENTRIES`. 142 - # 143 - # Default value: 100000 144 - #max_entries = 100000 145 - 146 138 [search] 147 139 # The heap size in bytes for the in-mem tantivy writer. Larger values 148 140 # trade RAM for fewer segment merges - the index itself lives in
+4 -1
web/src/lib/api/enrich.ts
··· 1 1 import type { BobbinContext, XrpcRequestInit } from "./client"; 2 2 import type { Nsid } from "@atcute/lexicons/syntax"; 3 - import type { MiniDoc } from "./identity"; 3 + import { INVALID_HANDLE, type MiniDoc } from "./identity"; 4 4 import { jsonPost } from "./_request"; 5 5 6 6 // payload types, also the keys payloads land under in the data sidecar ··· 64 64 source: LinkSource 65 65 ): MiniDoc | undefined => 66 66 did !== undefined ? (data[did]?.[source]?.[TYPE_MINIDOC] as MiniDoc | undefined) : undefined; 67 + 68 + export const handleOf = (data: Sidecar, did: string | undefined, source: LinkSource): string => 69 + miniDocOf(data, did, source)?.handle ?? INVALID_HANDLE;
+2
web/src/lib/api/identity.ts
··· 7 7 pds?: string; 8 8 } 9 9 10 + export const INVALID_HANDLE = "handle.invalid"; 11 + 10 12 export const resolveMiniDoc = ( 11 13 ctx: BobbinContext, 12 14 identifier: string,
+25
web/src/lib/components/profile/pages.test.ts
··· 111 111 ); 112 112 }); 113 113 114 + it("does not retry missing enriched identities", async () => { 115 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 116 + Response.json({ 117 + output: { 118 + items: [ 119 + { 120 + uri: "at://did:plc:bob/sh.tangled.graph.follow/one", 121 + value: { subject: "did:plc:alice", createdAt: "2026-08-01T00:00:00Z" } 122 + } 123 + ] 124 + }, 125 + data: {} 126 + }) 127 + ); 128 + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 129 + 130 + const page = await fetchPeoplePage(ctx, { 131 + did: "did:plc:alice", 132 + direction: "followers" 133 + }); 134 + 135 + expect(fetchMock).toHaveBeenCalledOnce(); 136 + expect(page.items[0].handle).toBe("handle.invalid"); 137 + }); 138 + 114 139 it("targets incoming vouch authors", async () => { 115 140 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(enriched()); 116 141 const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock });
+45 -93
web/src/lib/components/profile/pages.ts
··· 1 1 // page fetchers for the profile tabs, shared between the route load (first 2 2 // page) and the tab components (pagination). identities come from the enrich 3 - // sidecar's minidoc payloads, resolveMiniDoc only fires for sidecar misses 3 + // sidecar's minidoc payloads; misses render as handle.invalid without retrying 4 4 5 5 import type { BobbinContext } from "$lib/api/client"; 6 6 import type { Did } from "@atcute/lexicons/syntax"; 7 7 import { 8 8 enrich, 9 9 countOf, 10 + handleOf, 10 11 viewerUriOf, 11 - miniDocOf, 12 12 TYPE_COUNT, 13 13 TYPE_VIEWER, 14 14 TYPE_MINIDOC, ··· 17 17 type LinkSource 18 18 } from "$lib/api/enrich"; 19 19 import { fetchPage } from "$lib/api/pagination"; 20 - import { IdentityCache, type MiniDoc } from "$lib/api/identity"; 20 + import { IdentityCache, INVALID_HANDLE } from "$lib/api/identity"; 21 21 import type { RecordView, RepoRecord } from "$lib/api/records"; 22 22 import type { SearchPage } from "$lib/api/search"; 23 23 import { didFromUri, rkeyFromUri } from "$lib/api/uri"; ··· 106 106 return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; 107 107 }; 108 108 109 - const docOrResolve = ( 110 - data: Sidecar, 111 - source: LinkSource, 112 - cache: IdentityCache, 113 - did: string 114 - ): Promise<MiniDoc | null> => { 115 - const doc = miniDocOf(data, did, source); 116 - return doc ? Promise.resolve(doc) : cache.resolve(did).catch(() => null); 117 - }; 118 - 119 109 const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 120 110 const value = item.value as ShTangledString.Main; 121 111 return { ··· 128 118 }; 129 119 }; 130 120 131 - // the data sidecar already carries follower counts and viewer status, the 132 - // only extra cost is one miniDoc per did 133 - const resolvePeople = async ( 121 + const resolvePeople = ( 134 122 dids: string[], 135 123 data: Sidecar, 136 124 docSource: LinkSource, 137 - cache: IdentityCache, 138 125 viewerDid?: string 139 - ): Promise<PersonData[]> => { 140 - const unique = [...new Set(dids)]; 141 - 142 - const docs = await Promise.all(unique.map((did) => docOrResolve(data, docSource, cache, did))); 143 - 144 - const byDid = new Map<string, PersonData>(); 145 - unique.forEach((did, index) => { 146 - const doc = docs[index]; 126 + ): PersonData[] => { 127 + return [...new Set(dids)].map((did) => { 128 + const handle = handleOf(data, did, docSource); 147 129 const followers = countOf(data, did, "sh.tangled.graph.follow:subject"); 148 130 const following = countOf(data, did, "sh.tangled.graph.follow:.repo"); 149 131 const isSelf = viewerDid === did; 150 132 const viewerUri = viewerUriOf(data, did, FOLLOW_VIEWER.source); 151 133 const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; 152 - byDid.set( 153 - did, 154 - doc 155 - ? { 156 - did: doc.did, 157 - handle: doc.handle, 158 - followers, 159 - following, 160 - isSelf, 161 - viewerFollowRkey 162 - } 163 - : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 164 - ); 134 + return { did, handle, followers, following, isSelf, viewerFollowRkey }; 165 135 }); 166 - return unique.map((did) => byDid.get(did) as PersonData); 167 136 }; 168 137 169 138 const resolveVouches = async ( ··· 178 147 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 179 148 // outgoing vouches name the subject in the rkey, which the sidecar 180 149 // can't see. those still resolve client-side 181 - const doc = 182 - (direction === "incoming" && data 183 - ? miniDocOf(data, otherDid, VOUCHER_DOCS.source) 184 - : undefined) ?? (await cache.resolve(otherDid).catch(() => null)); 150 + const handle = 151 + direction === "incoming" && data 152 + ? handleOf(data, otherDid, VOUCHER_DOCS.source) 153 + : ((await cache.resolve(otherDid).catch(() => null))?.handle ?? INVALID_HANDLE); 185 154 return { 186 155 uri: item.uri, 187 156 did: otherDid, 188 - handle: doc?.handle ?? otherDid, 157 + handle, 189 158 kind: value.kind === "denounce" ? "denounce" : "vouch", 190 159 direction, 191 160 reason: value.reason, ··· 199 168 ctx: BobbinContext, 200 169 starData: Sidecar, 201 170 items: ListItem[], 202 - cache: IdentityCache, 203 171 viewerDid?: string 204 172 ): Promise<StarData[]> => { 205 173 const repoDids = [ ··· 224 192 const reposByDid = new Map( 225 193 enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) 226 194 ); 227 - const resolved = await Promise.all( 228 - items.map(async (item): Promise<StarData | null> => { 229 - const value = item.value as ShTangledFeedStar.Main; 230 - const subject = value.subject; 231 - if (subject && "did" in subject && subject.did) { 232 - const repo = reposByDid.get(subject.did); 233 - if (!repo) return null; 234 - const ownerDid = didFromUri(repo.uri); 235 - const owner = await docOrResolve(enriched.data, REPO_OWNER_DOCS.source, cache, ownerDid); 236 - return { 237 - kind: "repo", 238 - uri: item.uri, 239 - createdAt: value.createdAt, 240 - repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.data) 241 - }; 242 - } 243 - if (subject && "uri" in subject && subject.uri) { 244 - const ownerDid = didFromUri(subject.uri); 245 - const owner = await docOrResolve(starData, STAR_SUBJECT_DOCS.source, cache, ownerDid); 246 - return { 247 - kind: "string", 248 - uri: item.uri, 249 - createdAt: value.createdAt, 250 - ownerHandle: owner?.handle ?? ownerDid, 251 - rkey: rkeyFromUri(subject.uri) 252 - }; 253 - } 254 - return null; 255 - }) 256 - ); 195 + const resolved = items.map((item): StarData | null => { 196 + const value = item.value as ShTangledFeedStar.Main; 197 + const subject = value.subject; 198 + if (subject && "did" in subject && subject.did) { 199 + const repo = reposByDid.get(subject.did); 200 + if (!repo) return null; 201 + const ownerDid = didFromUri(repo.uri); 202 + const ownerHandle = handleOf(enriched.data, ownerDid, REPO_OWNER_DOCS.source); 203 + return { 204 + kind: "repo", 205 + uri: item.uri, 206 + createdAt: value.createdAt, 207 + repo: resolveRepoCard(repo, ownerHandle, enriched.data) 208 + }; 209 + } 210 + if (subject && "uri" in subject && subject.uri) { 211 + const ownerDid = didFromUri(subject.uri); 212 + const ownerHandle = handleOf(starData, ownerDid, STAR_SUBJECT_DOCS.source); 213 + return { 214 + kind: "string", 215 + uri: item.uri, 216 + createdAt: value.createdAt, 217 + ownerHandle, 218 + rkey: rkeyFromUri(subject.uri) 219 + }; 220 + } 221 + return null; 222 + }); 257 223 return resolved.filter((star): star is StarData => star !== null); 258 224 }; 259 225 ··· 323 289 did: string; 324 290 viewerDid?: string; 325 291 cursor?: string; 326 - cache?: IdentityCache; 327 292 limit?: number; 328 293 } 329 294 330 295 export const fetchStarredPage = async ( 331 296 ctx: BobbinContext, 332 - { did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions 297 + { did, viewerDid, cursor, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions 333 298 ): Promise<ListPage<StarData>> => { 334 299 const page = await enrich<RecordPage<ShTangledFeedStar.Main>>(ctx, { 335 300 xrpc: "sh.tangled.feed.listStarsBy", ··· 337 302 enrich: [target(STAR_SUBJECT_DOCS, ["items[].value.subject.uri"])] 338 303 }); 339 304 return { 340 - items: await resolveStars( 341 - ctx, 342 - page.data, 343 - page.output.items, 344 - cache ?? new IdentityCache(ctx), 345 - viewerDid 346 - ), 305 + items: await resolveStars(ctx, page.data, page.output.items, viewerDid), 347 306 cursor: page.output.cursor 348 307 }; 349 308 }; ··· 353 312 viewerDid?: string; 354 313 direction: "followers" | "following"; 355 314 cursor?: string; 356 - cache?: IdentityCache; 357 315 limit?: number; 358 316 } 359 317 360 318 export const fetchPeoplePage = async ( 361 319 ctx: BobbinContext, 362 - { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions 320 + { did, viewerDid, direction, cursor, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions 363 321 ): Promise<ListPage<PersonData>> => { 364 322 const docs = direction === "followers" ? FOLLOWER_DOCS : FOLLOWING_DOCS; 365 323 const targets = direction === "followers" ? ["items[].uri"] : ["items[].value.subject"]; ··· 378 336 ? enriched.output.items.map((item) => didFromUri(item.uri)) 379 337 : enriched.output.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 380 338 return { 381 - items: await resolvePeople( 382 - dids, 383 - enriched.data, 384 - docs.source, 385 - cache ?? new IdentityCache(ctx), 386 - viewerDid 387 - ), 339 + items: resolvePeople(dids, enriched.data, docs.source, viewerDid), 388 340 cursor: enriched.output.cursor 389 341 }; 390 342 };
+1 -6
web/src/lib/components/profile/tabs/PeopleTab.svelte
··· 2 2 import { untrack } from "svelte"; 3 3 import { getAuth } from "$lib/auth.svelte"; 4 4 import { createBobbinClient } from "$lib/api/client"; 5 - import { IdentityCache } from "$lib/api/identity"; 6 5 import FollowCard from "../FollowCard.svelte"; 7 6 import Section from "$lib/components/ui/Section.svelte"; 8 7 import Pagination from "$lib/components/ui/Pagination.svelte"; ··· 33 32 34 33 const auth = getAuth(); 35 34 36 - // shared across pages so repeat dids only resolve once 37 - let identityCache: IdentityCache | undefined; 38 35 const pager = createCursorPager( 39 36 { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, 40 37 (cursor) => { 41 38 const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 42 - identityCache ??= new IdentityCache(ctx); 43 39 return fetchPeoplePage(ctx, { 44 40 did, 45 41 viewerDid: auth.currentDid ?? undefined, 46 42 direction, 47 - cursor, 48 - cache: identityCache 43 + cursor 49 44 }); 50 45 } 51 46 );
+1 -6
web/src/lib/components/profile/tabs/StarredTab.svelte
··· 3 3 import { resolve } from "$app/paths"; 4 4 import { getAuth } from "$lib/auth.svelte"; 5 5 import { createBobbinClient } from "$lib/api/client"; 6 - import { IdentityCache } from "$lib/api/identity"; 7 6 import RepoCard from "$lib/components/repo/RepoCard.svelte"; 8 7 import Card from "$lib/components/ui/Card.svelte"; 9 8 import Section from "$lib/components/ui/Section.svelte"; ··· 24 23 25 24 const auth = getAuth(); 26 25 27 - // shared across pages so repeat repo owners only resolve once 28 - let identityCache: IdentityCache | undefined; 29 26 const pager = createCursorPager( 30 27 { items: untrack(() => initial), cursor: untrack(() => initialCursor) }, 31 28 (cursor) => { 32 29 const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 33 - identityCache ??= new IdentityCache(ctx); 34 30 return fetchStarredPage(ctx, { 35 31 did, 36 32 viewerDid: auth.currentDid ?? undefined, 37 - cursor, 38 - cache: identityCache 33 + cursor 39 34 }); 40 35 } 41 36 );
+13 -5
web/src/routes/[handle]/[repo]/+layout.ts
··· 1 1 import { error, redirect } from "@sveltejs/kit"; 2 2 import { createBobbinClient } from "$lib/api/client"; 3 3 import { count } from "$lib/api/count"; 4 + import { enrich, handleOf, TYPE_MINIDOC } from "$lib/api/enrich"; 4 5 import { gitTarget, resolveDefaultBranch } from "$lib/api/gitclient"; 5 6 import { getStarRkey } from "$lib/api/graph"; 6 - import { IdentityCache, resolveMiniDoc } from "$lib/api/identity"; 7 + import { resolveMiniDoc } from "$lib/api/identity"; 7 8 import { parallel, toHttpError } from "$lib/api/load"; 8 - import { getRepo } from "$lib/api/records"; 9 + import type { RecordView, RepoRecord } from "$lib/api/records"; 9 10 import { repoNameOf, resolveRepoByName } from "$lib/api/repo"; 10 11 import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 11 12 import type { BobbinContext } from "$lib/api/client"; ··· 20 21 ): Promise<RepoSource | null> => { 21 22 if (!uri?.startsWith("at://")) return null; 22 23 try { 23 - const view = await getRepo(ctx, uri); 24 + const page = await enrich<RecordView<RepoRecord>>(ctx, { 25 + xrpc: "sh.tangled.repo.getRepo", 26 + params: { repo: uri }, 27 + enrich: [{ source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC, targets: ["uri"] }] 28 + }); 29 + const view = page.output; 24 30 const ownerDid = didFromUri(view.uri); 25 - const owner = await new IdentityCache(ctx).resolve(ownerDid).catch(() => null); 26 - return { ownerHandle: owner?.handle ?? ownerDid, name: repoNameOf(view) }; 31 + return { 32 + ownerHandle: handleOf(page.data, ownerDid, "sh.tangled.repo:.repo"), 33 + name: repoNameOf(view) 34 + }; 27 35 } catch { 28 36 // a fork whose source is gone still renders, just without the attribution 29 37 return null;
+15 -21
web/src/routes/[handle]/[repo]/issues/+page.ts
··· 1 1 import { createBobbinClient } from "$lib/api/client"; 2 2 import { count } from "$lib/api/count"; 3 - import { enrich, miniDocOf, TYPE_MINIDOC } from "$lib/api/enrich"; 4 - import { IdentityCache } from "$lib/api/identity"; 3 + import { enrich, handleOf, TYPE_MINIDOC } from "$lib/api/enrich"; 5 4 import type { IssueListPage } from "$lib/api/records"; 6 5 import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 7 6 import type { IssueSummary } from "$lib/components/repo/types"; ··· 40 39 count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }).catch(() => null) 41 40 ]); 42 41 43 - const identity = new IdentityCache(ctx); 44 - const issues: IssueSummary[] = await Promise.all( 45 - page.output.items.map(async (item): Promise<IssueSummary> => { 46 - const authorDid = didFromUri(item.uri); 47 - const author = 48 - miniDocOf(page.data, authorDid, "sh.tangled.repo.issue:.repo") ?? 49 - (await identity.resolve(authorDid).catch(() => null)); 50 - return { 51 - uri: item.uri, 52 - rkey: rkeyFromUri(item.uri), 53 - title: item.value.title, 54 - state: item.state === "closed" ? "closed" : "open", 55 - authorDid, 56 - authorHandle: author?.handle ?? authorDid, 57 - createdAt: item.value.createdAt, 58 - commentCount: item.commentCount 59 - }; 60 - }) 61 - ); 42 + const issues: IssueSummary[] = page.output.items.map((item): IssueSummary => { 43 + const authorDid = didFromUri(item.uri); 44 + const authorHandle = handleOf(page.data, authorDid, "sh.tangled.repo.issue:.repo"); 45 + return { 46 + uri: item.uri, 47 + rkey: rkeyFromUri(item.uri), 48 + title: item.value.title, 49 + state: item.state === "closed" ? "closed" : "open", 50 + authorDid, 51 + authorHandle, 52 + createdAt: item.value.createdAt, 53 + commentCount: item.commentCount 54 + }; 55 + }); 62 56 63 57 return { 64 58 state: state as "open" | "closed",
+15 -26
web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts
··· 1 1 import { error } from "@sveltejs/kit"; 2 2 import { createBobbinClient } from "$lib/api/client"; 3 - import { enrich, miniDocOf, TYPE_MINIDOC, type LinkSource, type Sidecar } from "$lib/api/enrich"; 4 - import { IdentityCache, type MiniDoc } from "$lib/api/identity"; 3 + import { enrich, handleOf, miniDocOf, TYPE_MINIDOC } from "$lib/api/enrich"; 4 + import { INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; 5 5 import { 6 6 listIssueStates, 7 7 type CommentListPage, ··· 33 33 34 34 const record = issuePage.output; 35 35 const authorDid = didFromUri(record.uri); 36 - const identities = new IdentityCache(ctx); 37 - const docOrResolve = ( 38 - data: Sidecar, 39 - source: LinkSource, 40 - did: string 41 - ): Promise<MiniDoc | null> => { 42 - const doc = miniDocOf(data, did, source); 43 - return doc ? Promise.resolve(doc) : identities.resolve(did).catch(() => null); 44 - }; 45 36 const markupOpts = { 46 37 repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 47 38 ref: parent.repo.defaultBranch, 48 39 host: event.url.host 49 40 }; 50 41 51 - const [author, states, comments] = await Promise.all([ 52 - docOrResolve(issuePage.data, "sh.tangled.repo.issue:.repo", authorDid), 42 + const authorHandle = handleOf(issuePage.data, authorDid, "sh.tangled.repo.issue:.repo"); 43 + const [states, comments] = await Promise.all([ 53 44 listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null), 54 45 enrich<CommentListPage>(ctx, { 55 46 xrpc: "sh.tangled.feed.listComments", ··· 108 99 } 109 100 return undefined; 110 101 }; 111 - const reactorHandles = new Map<string, string>(); 112 - await Promise.all( 113 - [...reactorDids].map(async (did) => { 114 - const doc = reactorDoc(did) ?? (await identities.resolve(did).catch(() => null)); 115 - reactorHandles.set(did, doc?.handle ?? did); 116 - }) 102 + const reactorHandles = new Map( 103 + [...reactorDids].map((did) => [did, reactorDoc(did)?.handle ?? INVALID_HANDLE] as const) 117 104 ); 118 105 const reactionsFor = (subject: string): ReactionGroup[] => 119 106 buildReactions( 120 107 reactionsBySubject.get(subject) ?? [], 121 108 viewerDid, 122 - (did) => reactorHandles.get(did) ?? did 109 + (did) => reactorHandles.get(did) ?? INVALID_HANDLE 123 110 ); 124 111 125 112 const threadInputs: ThreadInput[] = await Promise.all( 126 113 commentItems.map(async (item): Promise<ThreadInput> => { 127 114 const commentDid = didFromUri(item.uri); 128 115 const commentBody = item.value.body?.text ?? ""; 129 - const [doc, commentBodyHtml] = await Promise.all([ 130 - docOrResolve(comments?.data ?? {}, "sh.tangled.feed.comment:.repo", commentDid), 131 - commentBody ? renderMarkup(commentBody, markupOpts) : Promise.resolve(null) 132 - ]); 116 + const authorHandle = handleOf( 117 + comments?.data ?? {}, 118 + commentDid, 119 + "sh.tangled.feed.comment:.repo" 120 + ); 121 + const commentBodyHtml = commentBody ? await renderMarkup(commentBody, markupOpts) : null; 133 122 return { 134 123 comment: { 135 124 uri: item.uri, 136 125 cid: item.cid, 137 126 rkey: rkeyFromUri(item.uri), 138 127 authorDid: commentDid, 139 - authorHandle: doc?.handle ?? commentDid, 128 + authorHandle, 140 129 createdAt: item.value.createdAt, 141 130 body: commentBody, 142 131 bodyHtml: commentBodyHtml, ··· 158 147 bodyHtml, 159 148 state, 160 149 authorDid, 161 - authorHandle: author?.handle ?? authorDid, 150 + authorHandle, 162 151 createdAt: record.value.createdAt, 163 152 reactions: reactionsFor(record.uri) 164 153 },