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 30, 2026, 7:45 PM +0300) commit 4ab35b52 parent a2efdca8 change-id zmmosvmz
+452 -15
+107 -1
bobbin/crates/ingest/src/lib.rs
··· 1051 1051 record.did.clone(), 1052 1052 record.rkey.clone(), 1053 1053 repo.repo_did.clone(), 1054 + repo.name.clone(), 1054 1055 ) 1055 1056 .await; 1056 1057 if let Some(prior) = superseded { ··· 2882 2883 Did::new_owned("did:plc:nel").unwrap(), 2883 2884 Rkey::new_owned("abcabcabcabcz").unwrap(), 2884 2885 Some(Did::new_owned("did:plc:abalone").unwrap()), 2886 + None, 2885 2887 ) 2886 2888 .await; 2887 2889 let issue: HydrantFrame = parse_frame(json!({ ··· 2942 2944 Did::new_owned("did:plc:nel").unwrap(), 2943 2945 Rkey::new_owned("abcabcabcabcz").unwrap(), 2944 2946 Some(Did::new_owned("did:plc:abalone").unwrap()), 2947 + None, 2945 2948 ) 2946 2949 .await; 2947 2950 let search = RecordingSearchSink::default(); ··· 2986 2989 } 2987 2990 2988 2991 #[tokio::test] 2992 + async fn repo_record_indexes_its_rkey_and_name() { 2993 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 2994 + let repo: HydrantFrame = parse_frame(json!({ 2995 + "id": 1, 2996 + "type": "record", 2997 + "record": { 2998 + "live": false, 2999 + "did": "did:plc:nel", 3000 + "rev": fresh_tid().as_str(), 3001 + "collection": "sh.tangled.repo", 3002 + "rkey": "abcabcabcabcz", 3003 + "action": "create", 3004 + "record": { 3005 + "$type": "sh.tangled.repo", 3006 + "createdAt": "2026-05-01T00:00:00Z", 3007 + "knot": "oyster.cafe", 3008 + "name": "abalone" 3009 + } 3010 + } 3011 + })); 3012 + handle_frame( 3013 + repo, 3014 + &store, 3015 + &issue_states, 3016 + &pull_statuses, 3017 + &cov, 3018 + &NoopSearchSink, 3019 + &NoopRecordStore, 3020 + &resolver, 3021 + &sys_clock(), 3022 + now(), 3023 + ) 3024 + .await; 3025 + let owner = Did::new_owned("did:plc:nel").unwrap(); 3026 + assert_eq!( 3027 + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, 3028 + Some(bobbin_types::ids::RepoIdent::new( 3029 + owner.clone(), 3030 + Rkey::new_owned("abcabcabcabcz").unwrap() 3031 + )), 3032 + ); 3033 + assert_eq!( 3034 + resolver.lookup_by_name(&owner, "abalone").await, 3035 + Some(bobbin_types::ids::RepoIdent::new( 3036 + owner.clone(), 3037 + Rkey::new_owned("abcabcabcabcz").unwrap() 3038 + )), 3039 + "the record's cosmetic name resolves to its rkey ident", 3040 + ); 3041 + } 3042 + 3043 + #[tokio::test] 3044 + async fn repo_record_without_a_name_indexes_its_rkey() { 3045 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 3046 + let repo: HydrantFrame = parse_frame(json!({ 3047 + "id": 1, 3048 + "type": "record", 3049 + "record": { 3050 + "live": false, 3051 + "did": "did:plc:nel", 3052 + "rev": fresh_tid().as_str(), 3053 + "collection": "sh.tangled.repo", 3054 + "rkey": "abalone", 3055 + "action": "create", 3056 + "record": { 3057 + "$type": "sh.tangled.repo", 3058 + "createdAt": "2026-05-01T00:00:00Z", 3059 + "knot": "oyster.cafe" 3060 + } 3061 + } 3062 + })); 3063 + handle_frame( 3064 + repo, 3065 + &store, 3066 + &issue_states, 3067 + &pull_statuses, 3068 + &cov, 3069 + &NoopSearchSink, 3070 + &NoopRecordStore, 3071 + &resolver, 3072 + &sys_clock(), 3073 + now(), 3074 + ) 3075 + .await; 3076 + let owner = Did::new_owned("did:plc:nel").unwrap(); 3077 + assert_eq!( 3078 + resolver.lookup_by_name(&owner, "abalone").await, 3079 + Some(bobbin_types::ids::RepoIdent::new( 3080 + owner, 3081 + Rkey::new_owned("abalone").unwrap() 3082 + )), 3083 + "repos made before the name field are only reachable by rkey", 3084 + ); 3085 + } 3086 + 3087 + #[tokio::test] 2989 3088 async fn delete_repo_record_evicts_resolver_cache() { 2990 3089 let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 2991 3090 let owner = Did::new_owned("did:plc:nel").unwrap(); ··· 2995 3094 owner.clone(), 2996 3095 rkey.clone(), 2997 3096 Some(Did::new_owned("did:plc:abalone").unwrap()), 3097 + None, 2998 3098 ) 2999 3099 .await; 3100 + resolver.observe_rkey(owner.clone(), rkey.clone()).await; 3000 3101 assert!( 3001 3102 resolver.cached_resolution(&owner, &rkey).await.is_some(), 3002 3103 "observe must seed the cache", ··· 3029 3130 assert!( 3030 3131 resolver.cached_resolution(&owner, &rkey).await.is_none(), 3031 3132 "deleting the repo record must clear the resolver cache so future observes are not blocked by a stale Authoritative entry", 3133 + ); 3134 + assert_eq!( 3135 + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, 3136 + None, 3137 + "a deleted repo must stop answering on its url", 3032 3138 ); 3033 3139 } 3034 3140 ··· 3181 3287 let slow_rkeys: [Rkey<DefaultStr>; 2] = [rkey("slowrkeyaa01"), rkey("slowrkeyaa02")]; 3182 3288 for r in &fast_rkeys { 3183 3289 resolver 3184 - .observe(owner.clone(), r.clone(), Some(abalone.clone())) 3290 + .observe(owner.clone(), r.clone(), Some(abalone.clone()), None) 3185 3291 .await; 3186 3292 } 3187 3293
+4 -2
bobbin/crates/resolver/src/legacy_upgrade.rs
··· 750 750 did("did:plc:scallop"), 751 751 rkey("limpet"), 752 752 Some(did("did:plc:scallop")), 753 + None, 753 754 ) 754 755 .await; 755 756 let canon = match decoded { ··· 781 782 let owner = did("did:plc:nel"); 782 783 let key = rkey("abcabcabcabcz"); 783 784 resolver 784 - .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop"))) 785 + .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")), None) 785 786 .await; 786 787 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#; 787 788 let legacy = ··· 918 919 did("did:plc:nel"), 919 920 rkey("abcabcabcabcz"), 920 921 Some(did("did:plc:scallop")), 922 + None, 921 923 ) 922 924 .await; 923 925 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","repoDid":""}}"#; ··· 1032 1034 let owner = did("did:plc:nel"); 1033 1035 let key = rkey("abcabcabcabcz"); 1034 1036 resolver 1035 - .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop"))) 1037 + .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")), None) 1036 1038 .await; 1037 1039 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#; 1038 1040 let legacy =
+158 -11
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>, 173 + by_name: SccMap<(Did<DefaultStr>, DefaultStr), RepoIdent, RuntimeHasher>, 172 174 in_flight: SccMap<RepoIdent, Arc<OnceCell<Resolution>>, RuntimeHasher>, 173 175 probe: Option<SlingshotProbe>, 174 176 stats: ResolverStats, ··· 183 185 Self { 184 186 cache: SccMap::with_hasher(hasher.clone()), 185 187 by_repo_did: SccMap::with_hasher(hasher.clone()), 188 + by_rkey: SccSet::with_hasher(hasher.clone()), 189 + by_name: SccMap::with_hasher(hasher.clone()), 186 190 in_flight: SccMap::with_hasher(hasher), 187 191 probe: Some(SlingshotProbe { client, clock }), 188 192 stats: ResolverStats::default(), ··· 193 197 Self { 194 198 cache: SccMap::with_hasher(hasher.clone()), 195 199 by_repo_did: SccMap::with_hasher(hasher.clone()), 200 + by_rkey: SccSet::with_hasher(hasher.clone()), 201 + by_name: SccMap::with_hasher(hasher.clone()), 196 202 in_flight: SccMap::with_hasher(hasher), 197 203 probe: None, 198 204 stats: ResolverStats::default(), ··· 226 232 .map(|e| e.get().clone()) 227 233 } 228 234 235 + pub async fn lookup_by_name(&self, owner: &Did<DefaultStr>, name: &str) -> Option<RepoIdent> { 236 + // rkey addressing wins so a record can't hijack another repo's rkey via its name 237 + if let Ok(rkey) = Rkey::new_owned(name) { 238 + let ident = RepoIdent::new(owner.clone(), rkey); 239 + if self.by_rkey.contains_async(&ident).await { 240 + return Some(ident); 241 + } 242 + } 243 + self.by_name 244 + .get_async(&(owner.clone(), DefaultStr::from(name))) 245 + .await 246 + .map(|e| e.get().clone()) 247 + } 248 + 249 + pub async fn observe_rkey(&self, owner: Did<DefaultStr>, rkey: Rkey<DefaultStr>) { 250 + let _ = self.by_rkey.insert_async(RepoIdent::new(owner, rkey)).await; 251 + } 252 + 229 253 pub async fn observe( 230 254 &self, 231 255 owner: Did<DefaultStr>, 232 256 rkey: Rkey<DefaultStr>, 233 257 repo_did: Option<Did<DefaultStr>>, 258 + name: Option<DefaultStr>, 234 259 ) -> Option<RepoIdent> { 235 260 let ident = RepoIdent::new(owner, rkey); 261 + self.observe_rkey(ident.owner.clone(), ident.rkey.clone()) 262 + .await; 263 + if let Some(name) = name { 264 + let _ = self 265 + .by_name 266 + .insert_async((ident.owner.clone(), name), ident.clone()) 267 + .await; 268 + } 236 269 let entry = 237 270 CacheEntry::Authoritative(AuthoritativeResolution::from_repo_did(repo_did.clone())); 238 271 self.cache ··· 268 301 .remove_if_async(&repo_did, |existing| *existing == ident) 269 302 .await; 270 303 } 304 + self.by_rkey.remove_async(&ident).await; 271 305 } 272 306 273 307 async fn fill_provisional(&self, key: RepoIdent, resolution: Resolution) { ··· 454 488 did("did:plc:nel"), 455 489 rkey("3liuighjy2h22"), 456 490 Some(did("did:plc:clam")), 491 + None, 457 492 ) 458 493 .await; 459 494 assert!(prior.is_none(), "first observation has no prior"); 460 495 461 496 let prior = resolver 462 - .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam"))) 497 + .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam")), None) 463 498 .await; 464 499 assert_eq!( 465 500 prior, ··· 468 503 ); 469 504 470 505 let prior = resolver 471 - .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam"))) 506 + .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam")), None) 472 507 .await; 473 508 assert!(prior.is_none(), "re-observing the same ident is a no-op"); 474 509 } ··· 477 512 async fn observation_without_repo_did_does_not_track_reverse() { 478 513 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 479 514 let prior = resolver 480 - .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None) 515 + .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None, None) 481 516 .await; 482 517 assert!(prior.is_none()); 483 518 } ··· 490 525 did("did:plc:nel"), 491 526 rkey("3liuighjy2h22"), 492 527 Some(did("did:plc:clam")), 528 + None, 493 529 ) 494 530 .await; 495 531 resolver 496 - .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam"))) 532 + .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam")), None) 497 533 .await; 498 534 499 535 resolver ··· 505 541 did("did:plc:nel"), 506 542 rkey("core-renamed"), 507 543 Some(did("did:plc:clam")), 544 + None, 508 545 ) 509 546 .await; 510 547 assert_eq!( ··· 515 552 } 516 553 517 554 #[tokio::test] 555 + async fn lookup_by_name_finds_observed_rkey() { 556 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 557 + resolver 558 + .observe_rkey(did("did:plc:nel"), rkey("3liuighjy2h22")) 559 + .await; 560 + let got = resolver 561 + .lookup_by_name(&did("did:plc:nel"), "3liuighjy2h22") 562 + .await; 563 + assert_eq!( 564 + got, 565 + Some(RepoIdent::new(did("did:plc:nel"), rkey("3liuighjy2h22"))), 566 + ); 567 + } 568 + 569 + #[tokio::test] 570 + async fn lookup_by_name_is_scoped_to_the_owner() { 571 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 572 + resolver 573 + .observe_rkey(did("did:plc:nel"), rkey("3liuighjy2h22")) 574 + .await; 575 + let got = resolver 576 + .lookup_by_name(&did("did:plc:olaren"), "3liuighjy2h22") 577 + .await; 578 + assert_eq!(got, None, "one owner's rkey must not answer for another's"); 579 + } 580 + 581 + #[tokio::test] 582 + async fn lookup_by_name_rejects_non_rkey() { 583 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 584 + let owner = did("did:plc:nel"); 585 + resolver 586 + .observe_rkey(owner.clone(), rkey("3liuighjy2h22")) 587 + .await; 588 + 589 + assert_eq!(resolver.lookup_by_name(&owner, "my repo").await, None); 590 + } 591 + 592 + #[tokio::test] 593 + async fn lookup_by_name_finds_observed_record_name() { 594 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 595 + resolver 596 + .observe( 597 + did("did:plc:nel"), 598 + rkey("3liuighjy2h22"), 599 + Some(did("did:plc:clam")), 600 + Some(DefaultStr::from("ark")), 601 + ) 602 + .await; 603 + let got = resolver.lookup_by_name(&did("did:plc:nel"), "ark").await; 604 + assert_eq!( 605 + got, 606 + Some(RepoIdent::new(did("did:plc:nel"), rkey("3liuighjy2h22"))), 607 + ); 608 + } 609 + 610 + #[tokio::test] 611 + async fn lookup_by_name_record_name_is_scoped_to_the_owner() { 612 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 613 + resolver 614 + .observe( 615 + did("did:plc:nel"), 616 + rkey("3liuighjy2h22"), 617 + None, 618 + Some(DefaultStr::from("ark")), 619 + ) 620 + .await; 621 + let got = resolver.lookup_by_name(&did("did:plc:olaren"), "ark").await; 622 + assert_eq!(got, None, "one owner's repo name must not answer for another's"); 623 + } 624 + 625 + #[tokio::test] 626 + async fn lookup_by_name_prefers_rkey_over_claimed_name() { 627 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 628 + resolver 629 + .observe(did("did:plc:nel"), rkey("core"), None, None) 630 + .await; 631 + resolver 632 + .observe( 633 + did("did:plc:nel"), 634 + rkey("3liuighjy2h22"), 635 + None, 636 + Some(DefaultStr::from("core")), 637 + ) 638 + .await; 639 + let got = resolver.lookup_by_name(&did("did:plc:nel"), "core").await; 640 + assert_eq!( 641 + got, 642 + Some(RepoIdent::new(did("did:plc:nel"), rkey("core"))), 643 + "a record naming itself after a live rkey must not shadow it", 644 + ); 645 + } 646 + 647 + #[tokio::test] 648 + async fn forget_clears_the_rkey() { 649 + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 650 + let owner = did("did:plc:nel"); 651 + resolver 652 + .observe_rkey(owner.clone(), rkey("3liuighjy2h22")) 653 + .await; 654 + resolver.forget(&owner, &rkey("3liuighjy2h22")).await; 655 + assert_eq!(resolver.lookup_by_name(&owner, "3liuighjy2h22").await, None); 656 + } 657 + 658 + #[tokio::test] 518 659 async fn observation_with_repo_did_resolves_mapped() { 519 660 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 520 661 resolver ··· 522 663 did("did:plc:nel"), 523 664 rkey("abcabcabcabcz"), 524 665 Some(did("did:plc:clam")), 666 + None, 525 667 ) 526 668 .await; 527 669 let got = resolver ··· 534 676 async fn observation_without_repo_did_resolves_no_repo_did() { 535 677 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 536 678 resolver 537 - .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None) 679 + .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None, None) 538 680 .await; 539 681 let got = resolver 540 682 .resolve(&did("did:plc:nel"), &rkey("abcabcabcabcz")) ··· 563 705 did("did:plc:nel"), 564 706 rkey("abcabcabcabcz"), 565 707 Some(did("did:plc:limpet")), 708 + None, 566 709 ) 567 710 .await; 568 711 let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; ··· 583 726 async fn lookup_by_repo_did_misses_when_repo_did_was_none() { 584 727 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 585 728 resolver 586 - .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None) 729 + .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None, None) 587 730 .await; 588 731 let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; 589 732 assert_eq!(got, None); ··· 597 740 did("did:plc:nel"), 598 741 rkey("abcabcabcabcz"), 599 742 Some(did("did:plc:limpet")), 743 + None, 600 744 ) 601 745 .await; 602 746 resolver ··· 604 748 did("did:plc:olaren"), 605 749 rkey("xyzxyzxyzxyzx"), 606 750 Some(did("did:plc:limpet")), 751 + None, 607 752 ) 608 753 .await; 609 754 let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; ··· 621 766 did("did:plc:nel"), 622 767 rkey("abcabcabcabcz"), 623 768 Some(did("did:plc:clam")), 769 + None, 624 770 ) 625 771 .await; 626 772 resolver ··· 628 774 did("did:plc:nel"), 629 775 rkey("abcabcabcabcz"), 630 776 Some(did("did:plc:uni")), 777 + None, 631 778 ) 632 779 .await; 633 780 let got = resolver ··· 642 789 let owner = did("did:plc:nel"); 643 790 let key = rkey("abcabcabcabcz"); 644 791 resolver 645 - .observe(owner.clone(), key.clone(), Some(did("did:plc:clam"))) 792 + .observe(owner.clone(), key.clone(), Some(did("did:plc:clam")), None) 646 793 .await; 647 794 resolver 648 795 .fill_provisional( ··· 663 810 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 664 811 let owner = did("did:plc:nel"); 665 812 let key = rkey("abcabcabcabcz"); 666 - resolver.observe(owner.clone(), key.clone(), None).await; 813 + resolver.observe(owner.clone(), key.clone(), None, None).await; 667 814 resolver 668 815 .fill_provisional( 669 816 RepoIdent::new(owner.clone(), key.clone()), ··· 1003 1150 Resolution::Mapped(did("did:plc:clam")), 1004 1151 ) 1005 1152 .await; 1006 - resolver.observe(owner.clone(), key.clone(), None).await; 1153 + resolver.observe(owner.clone(), key.clone(), None, None).await; 1007 1154 let got = resolver.resolve(&owner, &key).await; 1008 1155 assert_eq!( 1009 1156 got, ··· 1018 1165 let owner = did("did:plc:nel"); 1019 1166 let key = rkey("abcabcabcabcz"); 1020 1167 resolver 1021 - .observe(owner.clone(), key.clone(), Some(did("did:plc:clam"))) 1168 + .observe(owner.clone(), key.clone(), Some(did("did:plc:clam")), None) 1022 1169 .await; 1023 1170 assert_eq!( 1024 1171 resolver.cached_resolution(&owner, &key).await,
+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(
+108 -1
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() ··· 640 647 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; 641 648 state 642 649 .resolver 643 - .observe(owner_did.clone(), rk.clone(), Some(repo_did.clone())) 650 + .observe(owner_did.clone(), rk.clone(), Some(repo_did.clone()), None) 644 651 .await; 645 652 646 653 let app = router(state); ··· 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 + }