This repository has no description
0

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 28, 2026, 9:30 PM +0300) commit 10603031 parent 4109652a change-id zmmosvmz
+343 -1
+95
bobbin/crates/ingest/src/lib.rs
··· 2986 2986 } 2987 2987 2988 2988 #[tokio::test] 2989 + async fn repo_record_indexes_its_rkey() { 2990 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 2991 + let repo: HydrantFrame = parse_frame(json!({ 2992 + "id": 1, 2993 + "type": "record", 2994 + "record": { 2995 + "live": false, 2996 + "did": "did:plc:nel", 2997 + "rev": fresh_tid().as_str(), 2998 + "collection": "sh.tangled.repo", 2999 + "rkey": "abcabcabcabcz", 3000 + "action": "create", 3001 + "record": { 3002 + "$type": "sh.tangled.repo", 3003 + "createdAt": "2026-05-01T00:00:00Z", 3004 + "knot": "oyster.cafe", 3005 + "name": "abalone" 3006 + } 3007 + } 3008 + })); 3009 + handle_frame( 3010 + repo, 3011 + &store, 3012 + &issue_states, 3013 + &pull_statuses, 3014 + &cov, 3015 + &NoopSearchSink, 3016 + &NoopRecordStore, 3017 + &resolver, 3018 + &sys_clock(), 3019 + now(), 3020 + ) 3021 + .await; 3022 + let owner = Did::new_owned("did:plc:nel").unwrap(); 3023 + assert_eq!( 3024 + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, 3025 + Some(bobbin_types::ids::RepoIdent::new( 3026 + owner.clone(), 3027 + Rkey::new_owned("abcabcabcabcz").unwrap() 3028 + )), 3029 + ); 3030 + assert_eq!(resolver.lookup_by_name(&owner, "abalone").await, None); 3031 + } 3032 + 3033 + #[tokio::test] 3034 + async fn repo_record_without_a_name_indexes_its_rkey() { 3035 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 3036 + let repo: HydrantFrame = parse_frame(json!({ 3037 + "id": 1, 3038 + "type": "record", 3039 + "record": { 3040 + "live": false, 3041 + "did": "did:plc:nel", 3042 + "rev": fresh_tid().as_str(), 3043 + "collection": "sh.tangled.repo", 3044 + "rkey": "abalone", 3045 + "action": "create", 3046 + "record": { 3047 + "$type": "sh.tangled.repo", 3048 + "createdAt": "2026-05-01T00:00:00Z", 3049 + "knot": "oyster.cafe" 3050 + } 3051 + } 3052 + })); 3053 + handle_frame( 3054 + repo, 3055 + &store, 3056 + &issue_states, 3057 + &pull_statuses, 3058 + &cov, 3059 + &NoopSearchSink, 3060 + &NoopRecordStore, 3061 + &resolver, 3062 + &sys_clock(), 3063 + now(), 3064 + ) 3065 + .await; 3066 + let owner = Did::new_owned("did:plc:nel").unwrap(); 3067 + assert_eq!( 3068 + resolver.lookup_by_name(&owner, "abalone").await, 3069 + Some(bobbin_types::ids::RepoIdent::new( 3070 + owner, 3071 + Rkey::new_owned("abalone").unwrap() 3072 + )), 3073 + "repos made before the name field are only reachable by rkey", 3074 + ); 3075 + } 3076 + 3077 + #[tokio::test] 2989 3078 async fn delete_repo_record_evicts_resolver_cache() { 2990 3079 let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 2991 3080 let owner = Did::new_owned("did:plc:nel").unwrap(); ··· 2997 3086 Some(Did::new_owned("did:plc:abalone").unwrap()), 2998 3087 ) 2999 3088 .await; 3089 + resolver.observe_rkey(owner.clone(), rkey.clone()).await; 3000 3090 assert!( 3001 3091 resolver.cached_resolution(&owner, &rkey).await.is_some(), 3002 3092 "observe must seed the cache", ··· 3029 3119 assert!( 3030 3120 resolver.cached_resolution(&owner, &rkey).await.is_none(), 3031 3121 "deleting the repo record must clear the resolver cache so future observes are not blocked by a stale Authoritative entry", 3122 + ); 3123 + assert_eq!( 3124 + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, 3125 + None, 3126 + "a deleted repo must stop answering on its url", 3032 3127 ); 3033 3128 } 3034 3129
+66 -1
bobbin/crates/resolver/src/lib.rs
··· 21 21 use jacquard_common::types::did::Did; 22 22 use jacquard_common::types::nsid::Nsid; 23 23 use jacquard_common::types::recordkey::Rkey; 24 - use scc::HashMap as SccMap; 24 + use scc::{HashMap as SccMap, HashSet as SccSet}; 25 25 use tokio::sync::OnceCell; 26 26 use tracing::warn; 27 27 ··· 169 169 pub struct RepoIdResolver { 170 170 cache: SccMap<RepoIdent, CacheEntry, RuntimeHasher>, 171 171 by_repo_did: SccMap<Did<DefaultStr>, RepoIdent, RuntimeHasher>, 172 + by_rkey: SccSet<RepoIdent, RuntimeHasher>, 172 173 in_flight: SccMap<RepoIdent, Arc<OnceCell<Resolution>>, RuntimeHasher>, 173 174 probe: Option<SlingshotProbe>, 174 175 stats: ResolverStats, ··· 183 184 Self { 184 185 cache: SccMap::with_hasher(hasher.clone()), 185 186 by_repo_did: SccMap::with_hasher(hasher.clone()), 187 + by_rkey: SccSet::with_hasher(hasher.clone()), 186 188 in_flight: SccMap::with_hasher(hasher), 187 189 probe: Some(SlingshotProbe { client, clock }), 188 190 stats: ResolverStats::default(), ··· 193 195 Self { 194 196 cache: SccMap::with_hasher(hasher.clone()), 195 197 by_repo_did: SccMap::with_hasher(hasher.clone()), 198 + by_rkey: SccSet::with_hasher(hasher.clone()), 196 199 in_flight: SccMap::with_hasher(hasher), 197 200 probe: None, 198 201 stats: ResolverStats::default(), ··· 226 229 .map(|e| e.get().clone()) 227 230 } 228 231 232 + pub async fn lookup_by_name(&self, owner: &Did<DefaultStr>, name: &str) -> Option<RepoIdent> { 233 + let rkey = Rkey::new_owned(name).ok()?; 234 + let ident = RepoIdent::new(owner.clone(), rkey); 235 + self.by_rkey.contains_async(&ident).await.then_some(ident) 236 + } 237 + 238 + pub async fn observe_rkey(&self, owner: Did<DefaultStr>, rkey: Rkey<DefaultStr>) { 239 + let _ = self.by_rkey.insert_async(RepoIdent::new(owner, rkey)).await; 240 + } 241 + 229 242 pub async fn observe( 230 243 &self, 231 244 owner: Did<DefaultStr>, ··· 233 246 repo_did: Option<Did<DefaultStr>>, 234 247 ) -> Option<RepoIdent> { 235 248 let ident = RepoIdent::new(owner, rkey); 249 + self.observe_rkey(ident.owner.clone(), ident.rkey.clone()) 250 + .await; 236 251 let entry = 237 252 CacheEntry::Authoritative(AuthoritativeResolution::from_repo_did(repo_did.clone())); 238 253 self.cache ··· 268 283 .remove_if_async(&repo_did, |existing| *existing == ident) 269 284 .await; 270 285 } 286 + self.by_rkey.remove_async(&ident).await; 271 287 } 272 288 273 289 async fn fill_provisional(&self, key: RepoIdent, resolution: Resolution) { ··· 512 528 Some(RepoIdent::new(did("did:plc:nel"), rkey("core"))), 513 529 "stale at-uri's forget must not displace the live owner of did:plc:clam", 514 530 ); 531 + } 532 + 533 + #[tokio::test] 534 + async fn lookup_by_name_finds_observed_rkey() { 535 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 536 + resolver 537 + .observe_rkey(did("did:plc:nel"), rkey("3liuighjy2h22")) 538 + .await; 539 + let got = resolver 540 + .lookup_by_name(&did("did:plc:nel"), "3liuighjy2h22") 541 + .await; 542 + assert_eq!( 543 + got, 544 + Some(RepoIdent::new(did("did:plc:nel"), rkey("3liuighjy2h22"))), 545 + ); 546 + } 547 + 548 + #[tokio::test] 549 + async fn lookup_by_name_is_scoped_to_the_owner() { 550 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 551 + resolver 552 + .observe_rkey(did("did:plc:nel"), rkey("3liuighjy2h22")) 553 + .await; 554 + let got = resolver 555 + .lookup_by_name(&did("did:plc:olaren"), "3liuighjy2h22") 556 + .await; 557 + assert_eq!(got, None, "one owner's rkey must not answer for another's"); 558 + } 559 + 560 + #[tokio::test] 561 + async fn lookup_by_name_rejects_non_rkey() { 562 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 563 + let owner = did("did:plc:nel"); 564 + resolver 565 + .observe_rkey(owner.clone(), rkey("3liuighjy2h22")) 566 + .await; 567 + 568 + assert_eq!(resolver.lookup_by_name(&owner, "my repo").await, None); 569 + } 570 + 571 + #[tokio::test] 572 + async fn forget_clears_the_rkey() { 573 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 574 + let owner = did("did:plc:nel"); 575 + resolver 576 + .observe_rkey(owner.clone(), rkey("3liuighjy2h22")) 577 + .await; 578 + resolver.forget(&owner, &rkey("3liuighjy2h22")).await; 579 + assert_eq!(resolver.lookup_by_name(&owner, "3liuighjy2h22").await, None); 515 580 } 516 581 517 582 #[tokio::test]
+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(
+107
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("core"); 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_rkey(owner_did.clone(), rk.clone()) 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 + // the query name is the url rkey; the record's `name` is cosmetic 707 + ("name", rk.as_ref()), 708 + )) 709 + .await 710 + .unwrap(); 711 + let (status, body) = json_response(resp).await; 712 + assert_eq!(status, StatusCode::OK); 713 + assert_eq!( 714 + body["uri"], 715 + format!( 716 + "at://{}/sh.tangled.repo/{}", 717 + owner_did.as_ref(), 718 + rk.as_ref() 719 + ) 720 + ); 721 + assert_eq!(body["value"]["name"], "core"); 722 + } 723 + 724 + #[tokio::test] 725 + async fn get_repo_by_name_404_when_unobserved() { 726 + let server = MockServer::start().await; 727 + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; 728 + let app = router(state); 729 + let resp = app 730 + .oneshot(xrpc_request2( 731 + "sh.tangled.repo.getRepoByName", 732 + ("owner", "did:plc:scallop"), 733 + ("name", "core"), 734 + )) 735 + .await 736 + .unwrap(); 737 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 738 + } 739 + 740 + #[tokio::test] 741 + async fn get_repo_by_name_404_for_another_owners_name() { 742 + let server = MockServer::start().await; 743 + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; 744 + state 745 + .resolver 746 + .observe_rkey(did("did:plc:scallop"), rkey("core")) 747 + .await; 748 + let app = router(state); 749 + let resp = app 750 + .oneshot(xrpc_request2( 751 + "sh.tangled.repo.getRepoByName", 752 + ("owner", "did:plc:whelk"), 753 + ("name", "core"), 754 + )) 755 + .await 756 + .unwrap(); 757 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 758 + } 759 + 760 + #[tokio::test] 761 + async fn get_repo_by_name_400_on_invalid_owner() { 762 + let server = MockServer::start().await; 763 + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; 764 + let app = router(state); 765 + let resp = app 766 + .oneshot(xrpc_request2( 767 + "sh.tangled.repo.getRepoByName", 768 + ("owner", "not-a-did"), 769 + ("name", "core"), 770 + )) 771 + .await 772 + .unwrap(); 773 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 667 774 } 668 775 669 776 #[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 + }