Monorepo for Tangled tangled.org
3

Configure Feed

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

bobbin: add sh.tangled.repo.getRepoByName

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

author
dawn
date (Jul 25, 2026, 9:13 PM +0300) commit 69a0a7d2 parent 7e1f71c7 change-id tzsxqlqv
+444
+105
bobbin/crates/ingest/src/lib.rs
··· 1053 1053 repo.repo_did.clone(), 1054 1054 ) 1055 1055 .await; 1056 + // urls carry the cosmetic name, older repos only have their rkey 1057 + let name = repo 1058 + .name 1059 + .as_ref() 1060 + .map(AsRef::as_ref) 1061 + .unwrap_or_else(|| record.rkey.as_ref()); 1062 + ctx.resolver 1063 + .observe_name(record.did.clone(), record.rkey.clone(), name) 1064 + .await; 1056 1065 if let Some(prior) = superseded { 1057 1066 let prior_uri = format!( 1058 1067 "at://{}/sh.tangled.repo/{}", ··· 2986 2995 } 2987 2996 2988 2997 #[tokio::test] 2998 + async fn repo_record_indexes_its_name() { 2999 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 3000 + let repo: HydrantFrame = parse_frame(json!({ 3001 + "id": 1, 3002 + "type": "record", 3003 + "record": { 3004 + "live": false, 3005 + "did": "did:plc:nel", 3006 + "rev": fresh_tid().as_str(), 3007 + "collection": "sh.tangled.repo", 3008 + "rkey": "abcabcabcabcz", 3009 + "action": "create", 3010 + "record": { 3011 + "$type": "sh.tangled.repo", 3012 + "createdAt": "2026-05-01T00:00:00Z", 3013 + "knot": "oyster.cafe", 3014 + "name": "abalone" 3015 + } 3016 + } 3017 + })); 3018 + handle_frame( 3019 + repo, 3020 + &store, 3021 + &issue_states, 3022 + &pull_statuses, 3023 + &cov, 3024 + &NoopSearchSink, 3025 + &NoopRecordStore, 3026 + &resolver, 3027 + &sys_clock(), 3028 + now(), 3029 + ) 3030 + .await; 3031 + let owner = Did::new_owned("did:plc:nel").unwrap(); 3032 + assert_eq!( 3033 + resolver.lookup_by_name(&owner, "abalone").await, 3034 + Some(bobbin_types::ids::RepoIdent::new( 3035 + owner, 3036 + Rkey::new_owned("abcabcabcabcz").unwrap() 3037 + )), 3038 + ); 3039 + } 3040 + 3041 + #[tokio::test] 3042 + async fn repo_record_without_a_name_indexes_its_rkey() { 3043 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 3044 + let repo: HydrantFrame = parse_frame(json!({ 3045 + "id": 1, 3046 + "type": "record", 3047 + "record": { 3048 + "live": false, 3049 + "did": "did:plc:nel", 3050 + "rev": fresh_tid().as_str(), 3051 + "collection": "sh.tangled.repo", 3052 + "rkey": "abalone", 3053 + "action": "create", 3054 + "record": { 3055 + "$type": "sh.tangled.repo", 3056 + "createdAt": "2026-05-01T00:00:00Z", 3057 + "knot": "oyster.cafe" 3058 + } 3059 + } 3060 + })); 3061 + handle_frame( 3062 + repo, 3063 + &store, 3064 + &issue_states, 3065 + &pull_statuses, 3066 + &cov, 3067 + &NoopSearchSink, 3068 + &NoopRecordStore, 3069 + &resolver, 3070 + &sys_clock(), 3071 + now(), 3072 + ) 3073 + .await; 3074 + let owner = Did::new_owned("did:plc:nel").unwrap(); 3075 + assert_eq!( 3076 + resolver.lookup_by_name(&owner, "abalone").await, 3077 + Some(bobbin_types::ids::RepoIdent::new( 3078 + owner, 3079 + Rkey::new_owned("abalone").unwrap() 3080 + )), 3081 + "repos made before the name field are only reachable by rkey", 3082 + ); 3083 + } 3084 + 3085 + #[tokio::test] 2989 3086 async fn delete_repo_record_evicts_resolver_cache() { 2990 3087 let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 2991 3088 let owner = Did::new_owned("did:plc:nel").unwrap(); ··· 2996 3093 rkey.clone(), 2997 3094 Some(Did::new_owned("did:plc:abalone").unwrap()), 2998 3095 ) 3096 + .await; 3097 + resolver 3098 + .observe_name(owner.clone(), rkey.clone(), "abcabcabcabcz") 2999 3099 .await; 3000 3100 assert!( 3001 3101 resolver.cached_resolution(&owner, &rkey).await.is_some(), ··· 3029 3129 assert!( 3030 3130 resolver.cached_resolution(&owner, &rkey).await.is_none(), 3031 3131 "deleting the repo record must clear the resolver cache so future observes are not blocked by a stale Authoritative entry", 3132 + ); 3133 + assert_eq!( 3134 + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, 3135 + None, 3136 + "a deleted repo must stop answering on its url", 3032 3137 ); 3033 3138 } 3034 3139
+158
bobbin/crates/resolver/src/lib.rs
··· 72 72 } 73 73 } 74 74 75 + // repos are addressed by name in urls, so the name has to map back to an ident 76 + #[derive(Clone, Debug, Eq, Hash, PartialEq)] 77 + struct RepoNameKey { 78 + owner: Did<DefaultStr>, 79 + name: String, 80 + } 81 + 82 + impl RepoNameKey { 83 + fn new(owner: Did<DefaultStr>, name: &str) -> Self { 84 + Self { 85 + owner, 86 + name: name.to_owned(), 87 + } 88 + } 89 + } 90 + 75 91 #[derive(Default)] 76 92 pub struct ResolverStats { 77 93 hits: AtomicU64, ··· 169 185 pub struct RepoIdResolver { 170 186 cache: SccMap<RepoIdent, CacheEntry, RuntimeHasher>, 171 187 by_repo_did: SccMap<Did<DefaultStr>, RepoIdent, RuntimeHasher>, 188 + by_name: SccMap<RepoNameKey, RepoIdent, RuntimeHasher>, 189 + name_of: SccMap<RepoIdent, RepoNameKey, RuntimeHasher>, 172 190 in_flight: SccMap<RepoIdent, Arc<OnceCell<Resolution>>, RuntimeHasher>, 173 191 probe: Option<SlingshotProbe>, 174 192 stats: ResolverStats, ··· 183 201 Self { 184 202 cache: SccMap::with_hasher(hasher.clone()), 185 203 by_repo_did: SccMap::with_hasher(hasher.clone()), 204 + by_name: SccMap::with_hasher(hasher.clone()), 205 + name_of: SccMap::with_hasher(hasher.clone()), 186 206 in_flight: SccMap::with_hasher(hasher), 187 207 probe: Some(SlingshotProbe { client, clock }), 188 208 stats: ResolverStats::default(), ··· 193 213 Self { 194 214 cache: SccMap::with_hasher(hasher.clone()), 195 215 by_repo_did: SccMap::with_hasher(hasher.clone()), 216 + by_name: SccMap::with_hasher(hasher.clone()), 217 + name_of: SccMap::with_hasher(hasher.clone()), 196 218 in_flight: SccMap::with_hasher(hasher), 197 219 probe: None, 198 220 stats: ResolverStats::default(), ··· 226 248 .map(|e| e.get().clone()) 227 249 } 228 250 251 + pub async fn lookup_by_name(&self, owner: &Did<DefaultStr>, name: &str) -> Option<RepoIdent> { 252 + let key = RepoNameKey::new(owner.clone(), name); 253 + self.by_name.get_async(&key).await.map(|e| e.get().clone()) 254 + } 255 + 256 + pub async fn observe_name(&self, owner: Did<DefaultStr>, rkey: Rkey<DefaultStr>, name: &str) { 257 + let ident = RepoIdent::new(owner.clone(), rkey); 258 + let key = RepoNameKey::new(owner, name); 259 + 260 + // a rename leaves the old name pointing here, so give it up first 261 + if let Some(prior) = self 262 + .name_of 263 + .get_async(&ident) 264 + .await 265 + .map(|e| e.get().clone()) 266 + && prior != key 267 + { 268 + self.by_name 269 + .remove_if_async(&prior, |existing| *existing == ident) 270 + .await; 271 + } 272 + 273 + self.name_of 274 + .entry_async(ident.clone()) 275 + .await 276 + .and_modify(|existing| *existing = key.clone()) 277 + .or_insert(key.clone()); 278 + 279 + // two repos claiming one name is the owner's problem 280 + // we just resolve to the last one 281 + self.by_name 282 + .entry_async(key) 283 + .await 284 + .and_modify(|existing| *existing = ident.clone()) 285 + .or_insert(ident); 286 + } 287 + 229 288 pub async fn observe( 230 289 &self, 231 290 owner: Did<DefaultStr>, ··· 266 325 if let Some(Resolution::Mapped(repo_did)) = prior_resolution { 267 326 self.by_repo_did 268 327 .remove_if_async(&repo_did, |existing| *existing == ident) 328 + .await; 329 + } 330 + if let Some((_, name)) = self.name_of.remove_async(&ident).await { 331 + self.by_name 332 + .remove_if_async(&name, |existing| *existing == ident) 269 333 .await; 270 334 } 271 335 } ··· 511 575 prior, 512 576 Some(RepoIdent::new(did("did:plc:nel"), rkey("core"))), 513 577 "stale at-uri's forget must not displace the live owner of did:plc:clam", 578 + ); 579 + } 580 + 581 + #[tokio::test] 582 + async fn lookup_by_name_finds_observed_ident() { 583 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 584 + resolver 585 + .observe_name(did("did:plc:nel"), rkey("3liuighjy2h22"), "core") 586 + .await; 587 + let got = resolver.lookup_by_name(&did("did:plc:nel"), "core").await; 588 + assert_eq!( 589 + got, 590 + Some(RepoIdent::new(did("did:plc:nel"), rkey("3liuighjy2h22"))), 591 + ); 592 + } 593 + 594 + #[tokio::test] 595 + async fn lookup_by_name_is_scoped_to_the_owner() { 596 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 597 + resolver 598 + .observe_name(did("did:plc:nel"), rkey("3liuighjy2h22"), "core") 599 + .await; 600 + let got = resolver 601 + .lookup_by_name(&did("did:plc:olaren"), "core") 602 + .await; 603 + assert_eq!(got, None, "one owner's name must not answer for another's"); 604 + } 605 + 606 + #[tokio::test] 607 + async fn rename_drops_the_old_name() { 608 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 609 + let owner = did("did:plc:nel"); 610 + resolver 611 + .observe_name(owner.clone(), rkey("3liuighjy2h22"), "core") 612 + .await; 613 + resolver 614 + .observe_name(owner.clone(), rkey("3liuighjy2h22"), "tangled") 615 + .await; 616 + 617 + assert_eq!( 618 + resolver.lookup_by_name(&owner, "core").await, 619 + None, 620 + "the old name must stop resolving or a renamed repo answers on two urls", 621 + ); 622 + assert_eq!( 623 + resolver.lookup_by_name(&owner, "tangled").await, 624 + Some(RepoIdent::new(owner, rkey("3liuighjy2h22"))), 625 + ); 626 + } 627 + 628 + #[tokio::test] 629 + async fn a_taken_name_goes_to_the_last_observed_repo() { 630 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 631 + let owner = did("did:plc:nel"); 632 + resolver 633 + .observe_name(owner.clone(), rkey("3liuighjy2h22"), "core") 634 + .await; 635 + resolver 636 + .observe_name(owner.clone(), rkey("xyzxyzxyzxyzx"), "core") 637 + .await; 638 + assert_eq!( 639 + resolver.lookup_by_name(&owner, "core").await, 640 + Some(RepoIdent::new(owner, rkey("xyzxyzxyzxyzx"))), 641 + ); 642 + } 643 + 644 + #[tokio::test] 645 + async fn forget_clears_the_name() { 646 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 647 + let owner = did("did:plc:nel"); 648 + resolver 649 + .observe_name(owner.clone(), rkey("3liuighjy2h22"), "core") 650 + .await; 651 + resolver.forget(&owner, &rkey("3liuighjy2h22")).await; 652 + assert_eq!(resolver.lookup_by_name(&owner, "core").await, None); 653 + } 654 + 655 + #[tokio::test] 656 + async fn forget_leaves_a_name_that_moved_on() { 657 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 658 + let owner = did("did:plc:nel"); 659 + resolver 660 + .observe_name(owner.clone(), rkey("3liuighjy2h22"), "core") 661 + .await; 662 + resolver 663 + .observe_name(owner.clone(), rkey("xyzxyzxyzxyzx"), "core") 664 + .await; 665 + 666 + resolver.forget(&owner, &rkey("3liuighjy2h22")).await; 667 + 668 + assert_eq!( 669 + resolver.lookup_by_name(&owner, "core").await, 670 + Some(RepoIdent::new(owner, rkey("xyzxyzxyzxyzx"))), 671 + "deleting the repo that lost the name must not unhook the one that has it", 514 672 ); 515 673 } 516 674
+30
bobbin/crates/xrpc/src/lib.rs
··· 181 181 "/xrpc/sh.tangled.repo.getReposByRepoDids", 182 182 get(get_repos_by_repo_dids), 183 183 ) 184 + .route("/xrpc/sh.tangled.repo.getRepoByName", get(get_repo_by_name)) 184 185 .route("/xrpc/sh.tangled.actor.getProfile", get(get_profile)) 185 186 .route("/xrpc/sh.tangled.actor.getProfiles", get(get_profiles)) 186 187 .route("/xrpc/sh.tangled.repo.getIssue", get(get_issue)) ··· 605 606 struct GetRepoByRepoDidQuery { 606 607 #[serde(rename = "repoDid")] 607 608 repo_did: Did<DefaultStr>, 609 + } 610 + 611 + #[derive(Debug, Deserialize)] 612 + struct GetRepoByNameQuery { 613 + owner: Did<DefaultStr>, 614 + name: String, 608 615 } 609 616 610 617 #[derive(Debug, Deserialize)] ··· 1275 1282 let ident = state 1276 1283 .resolver 1277 1284 .lookup_by_repo_did(&q.repo_did) 1285 + .await 1286 + .ok_or(XrpcError::NotFound)?; 1287 + let uri = AtUri::<DefaultStr>::from_parts_owned( 1288 + ident.owner.as_str(), 1289 + RepoRecord::NSID, 1290 + ident.rkey.as_str(), 1291 + ) 1292 + .expect("Did and Rkey newtypes already validated, at-uri assembly cannot fail"); 1293 + let (body, value) = fetch_from_uri::<RepoRecord, Repo<DefaultStr>>(&state, uri).await?; 1294 + Ok(Json(Deduped(RepoGetRecordOutput { 1295 + cid: Some(body.cid.clone()), 1296 + uri: body.uri.clone(), 1297 + value, 1298 + }))) 1299 + } 1300 + 1301 + async fn get_repo_by_name( 1302 + State(state): State<AppState>, 1303 + XrpcQuery(q): XrpcQuery<GetRepoByNameQuery>, 1304 + ) -> Result<Json<Deduped<RepoGetRecordOutput<DefaultStr>>>, XrpcError> { 1305 + let ident = state 1306 + .resolver 1307 + .lookup_by_name(&q.owner, &q.name) 1278 1308 .await 1279 1309 .ok_or(XrpcError::NotFound)?; 1280 1310 let uri = AtUri::<DefaultStr>::from_parts_owned(
+106
bobbin/crates/xrpc/tests/cold_start.rs
··· 90 90 .unwrap() 91 91 } 92 92 93 + fn xrpc_request2(endpoint: &str, a: (&str, &str), b: (&str, &str)) -> Request<Body> { 94 + Request::builder() 95 + .uri(format!("/xrpc/{endpoint}?{}={}&{}={}", a.0, a.1, b.0, b.1)) 96 + .body(Body::empty()) 97 + .unwrap() 98 + } 99 + 93 100 fn xrpc_request_escaped(endpoint: &str, param: &str, value: &str) -> Request<Body> { 94 101 let encoded: String = byte_serialize(value.as_bytes()).collect(); 95 102 Request::builder() ··· 664 671 ); 665 672 assert_eq!(body["value"]["name"], "scallop"); 666 673 assert_eq!(body["value"]["repoDid"], repo_did.as_ref()); 674 + } 675 + 676 + #[tokio::test] 677 + async fn get_repo_by_name_returns_observed_record() { 678 + let server = MockServer::start().await; 679 + let owner_did = did("did:plc:scallop"); 680 + let rk = rkey("3liuighjy2h22"); 681 + mount_record( 682 + &server, 683 + &owner_did, 684 + &nsid("sh.tangled.repo"), 685 + &rk, 686 + json!({ 687 + "$type": "sh.tangled.repo", 688 + "name": "core", 689 + "knot": "oyster.cafe", 690 + "createdAt": "2026-05-01T00:00:00Z", 691 + }), 692 + ) 693 + .await; 694 + 695 + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; 696 + state 697 + .resolver 698 + .observe_name(owner_did.clone(), rk.clone(), "core") 699 + .await; 700 + 701 + let app = router(state); 702 + let resp = app 703 + .oneshot(xrpc_request2( 704 + "sh.tangled.repo.getRepoByName", 705 + ("owner", owner_did.as_ref()), 706 + ("name", "core"), 707 + )) 708 + .await 709 + .unwrap(); 710 + let (status, body) = json_response(resp).await; 711 + assert_eq!(status, StatusCode::OK); 712 + assert_eq!( 713 + body["uri"], 714 + format!( 715 + "at://{}/sh.tangled.repo/{}", 716 + owner_did.as_ref(), 717 + rk.as_ref() 718 + ) 719 + ); 720 + assert_eq!(body["value"]["name"], "core"); 721 + } 722 + 723 + #[tokio::test] 724 + async fn get_repo_by_name_404_when_unobserved() { 725 + let server = MockServer::start().await; 726 + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; 727 + let app = router(state); 728 + let resp = app 729 + .oneshot(xrpc_request2( 730 + "sh.tangled.repo.getRepoByName", 731 + ("owner", "did:plc:scallop"), 732 + ("name", "core"), 733 + )) 734 + .await 735 + .unwrap(); 736 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 737 + } 738 + 739 + #[tokio::test] 740 + async fn get_repo_by_name_404_for_another_owners_name() { 741 + let server = MockServer::start().await; 742 + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; 743 + state 744 + .resolver 745 + .observe_name(did("did:plc:scallop"), rkey("3liuighjy2h22"), "core") 746 + .await; 747 + let app = router(state); 748 + let resp = app 749 + .oneshot(xrpc_request2( 750 + "sh.tangled.repo.getRepoByName", 751 + ("owner", "did:plc:whelk"), 752 + ("name", "core"), 753 + )) 754 + .await 755 + .unwrap(); 756 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 757 + } 758 + 759 + #[tokio::test] 760 + async fn get_repo_by_name_400_on_invalid_owner() { 761 + let server = MockServer::start().await; 762 + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; 763 + let app = router(state); 764 + let resp = app 765 + .oneshot(xrpc_request2( 766 + "sh.tangled.repo.getRepoByName", 767 + ("owner", "not-a-did"), 768 + ("name", "core"), 769 + )) 770 + .await 771 + .unwrap(); 772 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 667 773 } 668 774 669 775 #[tokio::test]
+45
lexicons/repo/getRepoByName.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getRepoByName", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["owner", "name"], 10 + "properties": { 11 + "owner": { 12 + "type": "string", 13 + "format": "did", 14 + "description": "DID of the account that owns the repo." 15 + }, 16 + "name": { 17 + "type": "string", 18 + "description": "Name of the repo as it appears in its url." 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["uri", "value"], 27 + "properties": { 28 + "uri": { 29 + "type": "string", 30 + "format": "at-uri" 31 + }, 32 + "cid": { 33 + "type": "string", 34 + "format": "cid" 35 + }, 36 + "value": { 37 + "type": "unknown", 38 + "description": "Embedded sh.tangled.repo record." 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + }