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.countForks

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

author
dawn
date (Jul 31, 2026, 10:53 PM +0300) commit 1c131fbc parent ee71ada3 change-id sxzoouoq
+355 -2
+129
bobbin/crates/ingest/src/lib.rs
··· 3041 3041 } 3042 3042 3043 3043 #[tokio::test] 3044 + async fn fork_record_edges_against_its_source() { 3045 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 3046 + let fork: HydrantFrame = parse_frame(json!({ 3047 + "id": 1, 3048 + "type": "record", 3049 + "record": { 3050 + "live": false, 3051 + "did": "did:plc:olaren", 3052 + "rev": fresh_tid().as_str(), 3053 + "collection": "sh.tangled.repo", 3054 + "rkey": "abcabcabcabcz", 3055 + "action": "create", 3056 + "record": { 3057 + "$type": "sh.tangled.repo", 3058 + "createdAt": "2026-05-01T00:00:00Z", 3059 + "knot": "oyster.cafe", 3060 + "name": "abalone", 3061 + "source": "did:plc:abalone" 3062 + } 3063 + } 3064 + })); 3065 + handle_frame( 3066 + fork, 3067 + &store, 3068 + &issue_states, 3069 + &pull_statuses, 3070 + &cov, 3071 + &NoopSearchSink, 3072 + &NoopRecordStore, 3073 + &resolver, 3074 + &sys_clock(), 3075 + now(), 3076 + ) 3077 + .await; 3078 + let key = bobbin_types::ids::EdgeKey::new( 3079 + Nsid::new_static(bobbin_types::edges::REPO_SOURCE_EDGE_KIND).unwrap(), 3080 + did_subj("did:plc:abalone"), 3081 + ); 3082 + assert_eq!(store.count(&key), 1); 3083 + } 3084 + 3085 + fn fork_frame(source: &str) -> HydrantFrame { 3086 + parse_frame(json!({ 3087 + "id": 1, 3088 + "type": "record", 3089 + "record": { 3090 + "live": false, 3091 + "did": "did:plc:olaren", 3092 + "rev": fresh_tid().as_str(), 3093 + "collection": "sh.tangled.repo", 3094 + "rkey": "abcabcabcabcz", 3095 + "action": "create", 3096 + "record": { 3097 + "$type": "sh.tangled.repo", 3098 + "createdAt": "2026-05-01T00:00:00Z", 3099 + "knot": "oyster.cafe", 3100 + "name": "abalone", 3101 + "source": source 3102 + } 3103 + } 3104 + })) 3105 + } 3106 + 3107 + fn fork_edge_key(subject: SubjectRef) -> bobbin_types::ids::EdgeKey { 3108 + bobbin_types::ids::EdgeKey::new( 3109 + Nsid::new_static(bobbin_types::edges::REPO_SOURCE_EDGE_KIND).unwrap(), 3110 + subject, 3111 + ) 3112 + } 3113 + 3114 + #[tokio::test] 3115 + async fn fork_by_at_uri_edges_against_the_source_did() { 3116 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 3117 + resolver 3118 + .observe( 3119 + Did::new_owned("did:plc:nel").unwrap(), 3120 + Rkey::new_owned("core").unwrap(), 3121 + Some(Did::new_owned("did:plc:abalone").unwrap()), 3122 + None, 3123 + ) 3124 + .await; 3125 + handle_frame( 3126 + fork_frame("at://did:plc:nel/sh.tangled.repo/core"), 3127 + &store, 3128 + &issue_states, 3129 + &pull_statuses, 3130 + &cov, 3131 + &NoopSearchSink, 3132 + &NoopRecordStore, 3133 + &resolver, 3134 + &sys_clock(), 3135 + now(), 3136 + ) 3137 + .await; 3138 + assert_eq!(store.count(&fork_edge_key(did_subj("did:plc:abalone"))), 1); 3139 + assert_eq!( 3140 + store.count(&fork_edge_key(uri_subj( 3141 + "at://did:plc:nel/sh.tangled.repo/core" 3142 + ))), 3143 + 0, 3144 + "the rkey form would never match a bare-DID query", 3145 + ); 3146 + } 3147 + 3148 + #[tokio::test] 3149 + async fn fork_of_a_repo_without_a_did_has_nothing_to_count_against() { 3150 + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 3151 + handle_frame( 3152 + fork_frame("at://did:plc:nel/sh.tangled.repo/core"), 3153 + &store, 3154 + &issue_states, 3155 + &pull_statuses, 3156 + &cov, 3157 + &NoopSearchSink, 3158 + &NoopRecordStore, 3159 + &resolver, 3160 + &sys_clock(), 3161 + now(), 3162 + ) 3163 + .await; 3164 + assert_eq!( 3165 + store.count(&fork_edge_key(uri_subj( 3166 + "at://did:plc:nel/sh.tangled.repo/core" 3167 + ))), 3168 + 0, 3169 + ); 3170 + } 3171 + 3172 + #[tokio::test] 3044 3173 async fn repo_record_without_a_name_indexes_its_rkey() { 3045 3174 let (store, issue_states, pull_statuses, cov, resolver) = fresh(); 3046 3175 let repo: HydrantFrame = parse_frame(json!({
+98 -2
bobbin/crates/types/src/edges.rs
··· 3 3 4 4 use jacquard_common::types::did::Did; 5 5 use jacquard_common::types::nsid::Nsid; 6 - use jacquard_common::types::string::{AtStrError, AtUri, Datetime}; 6 + use jacquard_common::types::string::{AtStrError, AtUri, Datetime, UriValue}; 7 7 use jacquard_common::types::tid::Tid; 8 8 use jacquard_common::{BosStr, DefaultStr}; 9 9 ··· 228 228 Self::Knot(_) => Ok(owner_self_edges("sh.tangled.knot", source)), 229 229 Self::LabelDefinition(_) => Ok(owner_self_edges("sh.tangled.label.definition", source)), 230 230 Self::PublicKey(_) => Ok(owner_self_edges("sh.tangled.publicKey", source)), 231 - Self::Repo(_) => Ok(owner_self_edges("sh.tangled.repo", source)), 231 + Self::Repo(r) => Ok(repo_edges(source, r)), 232 232 Self::Spindle(_) => Ok(owner_self_edges("sh.tangled.spindle", source)), 233 233 Self::TangledString(_) => Ok(owner_self_edges("sh.tangled.string", source)), 234 234 Self::Vouch(r) => vouch_edges(source, r), 235 235 Self::Profile(_) => Ok(Vec::new()), 236 236 } 237 + } 238 + } 239 + 240 + pub const REPO_SOURCE_EDGE_KIND: &str = "sh.tangled.repo.source"; 241 + 242 + fn repo_edges(source: &AtUri<DefaultStr>, record: &RepoRecord<DefaultStr>) -> Vec<Edge> { 243 + let mut edges = owner_self_edges("sh.tangled.repo", source); 244 + if let Some(subject) = fork_source_subject(record) { 245 + edges.push(Edge { 246 + kind: nsid_static(REPO_SOURCE_EDGE_KIND), 247 + subject, 248 + source: source.clone(), 249 + sort_micros: 0, 250 + }); 251 + } 252 + edges 253 + } 254 + 255 + fn fork_source_subject(record: &RepoRecord<DefaultStr>) -> Option<SubjectRef> { 256 + match record.source.as_ref()? { 257 + UriValue::Did(did) => Some(SubjectRef::Did(did.clone())), 258 + UriValue::At(uri) => uri_subject_for_record(uri), 259 + _ => None, 237 260 } 238 261 } 239 262 ··· 578 601 fn extract(collection: &'static str, source: &str, body: serde_json::Value) -> Vec<Edge> { 579 602 let parsed = Record::from_json_value(&nsid(collection), body).expect("parse"); 580 603 parsed.primary_edges(&at(source)).expect("extract") 604 + } 605 + 606 + fn repo_body(source: Option<&str>) -> serde_json::Value { 607 + let mut body = json!({ 608 + "$type": "sh.tangled.repo", 609 + "createdAt": "2026-05-01T00:00:00Z", 610 + "knot": "oyster.cafe", 611 + "name": "abalone" 612 + }); 613 + if let Some(source) = source { 614 + body["source"] = json!(source); 615 + } 616 + body 617 + } 618 + 619 + #[test] 620 + fn plain_repo_only_makes_its_owner_edge() { 621 + let edges = extract( 622 + "sh.tangled.repo", 623 + "at://did:plc:nel/sh.tangled.repo/abcabcabcabcz", 624 + repo_body(None), 625 + ); 626 + assert_eq!(edges.len(), 1); 627 + assert_eq!(edges[0].kind, nsid("sh.tangled.repo")); 628 + assert_eq!(edges[0].subject, did_subj("did:plc:nel")); 629 + } 630 + 631 + #[test] 632 + fn fork_of_a_repo_did_keys_on_the_did() { 633 + let edges = extract( 634 + "sh.tangled.repo", 635 + "at://did:plc:nel/sh.tangled.repo/abcabcabcabcz", 636 + repo_body(Some("did:plc:abalone")), 637 + ); 638 + let fork: Vec<&Edge> = edges 639 + .iter() 640 + .filter(|e| e.kind.as_ref() == REPO_SOURCE_EDGE_KIND) 641 + .collect(); 642 + assert_eq!(fork.len(), 1); 643 + assert_eq!(fork[0].subject, did_subj("did:plc:abalone")); 644 + } 645 + 646 + #[test] 647 + fn fork_of_a_repo_without_a_did_keys_on_the_record() { 648 + let edges = extract( 649 + "sh.tangled.repo", 650 + "at://did:plc:nel/sh.tangled.repo/abcabcabcabcz", 651 + repo_body(Some("at://did:plc:olaren/sh.tangled.repo/core")), 652 + ); 653 + let fork: Vec<&Edge> = edges 654 + .iter() 655 + .filter(|e| e.kind.as_ref() == REPO_SOURCE_EDGE_KIND) 656 + .collect(); 657 + assert_eq!(fork.len(), 1); 658 + assert_eq!( 659 + fork[0].subject, 660 + uri_subj("at://did:plc:olaren/sh.tangled.repo/core") 661 + ); 662 + } 663 + 664 + #[test] 665 + fn fork_of_something_off_network_makes_no_fork_edge() { 666 + let edges = extract( 667 + "sh.tangled.repo", 668 + "at://did:plc:nel/sh.tangled.repo/abcabcabcabcz", 669 + repo_body(Some("https://github.com/90-008/awawa")), 670 + ); 671 + assert!( 672 + edges 673 + .iter() 674 + .all(|e| e.kind.as_ref() != REPO_SOURCE_EDGE_KIND), 675 + "a clone url is not a repo we can count against", 676 + ); 581 677 } 582 678 583 679 #[test]
+19
bobbin/crates/xrpc/src/lib.rs
··· 33 33 SearchCursor, SearchError, SearchFilters, SearchHit, SearchOffset, SearchReader, 34 34 }; 35 35 use bobbin_slingshot_client::{SlingshotClient, SlingshotError}; 36 + use bobbin_types::edges::REPO_SOURCE_EDGE_KIND; 36 37 use bobbin_types::ids::{EdgeKey, SubjectRef, nsid_static}; 37 38 use bobbin_types::knot_acl::{KnotOwnedSource, decode_knot_owned_source, knot_did_host}; 38 39 use bobbin_types::record::RecordBody; ··· 250 251 ) 251 252 .route("/xrpc/sh.tangled.repo.listRepos", get(list_repos)) 252 253 .route("/xrpc/sh.tangled.repo.countRepos", get(count_repos)) 254 + .route("/xrpc/sh.tangled.repo.countForks", get(count_forks)) 253 255 .route("/xrpc/sh.tangled.knot.listKnots", get(list_knots)) 254 256 .route("/xrpc/sh.tangled.knot.countKnots", get(count_knots)) 255 257 .route("/xrpc/sh.tangled.spindle.listSpindles", get(list_spindles)) ··· 1029 1031 "sh.tangled.spindle.member" => SpindleMemberRecord, SubjectShape::BareDid, mirror SpindleMemberBy; 1030 1032 "sh.tangled.string" => TangledStringRecord, SubjectShape::BareDid; 1031 1033 } 1034 + 1035 + // the fork edge is not a collection so it is not in the table above 1036 + const FORK_SUBJECT_SHAPE: SubjectShape = SubjectShape::BareDid; 1032 1037 1033 1038 fn parse_subject(raw: &SubjectQuery, shape: SubjectShape) -> Result<SubjectRef, XrpcError> { 1034 1039 let uri = match raw { ··· 2115 2120 XrpcQuery(q): XrpcQuery<CountQuery>, 2116 2121 ) -> Result<Json<CountResponse>, XrpcError> { 2117 2122 count_for::<RepoRecord>(&state, q).map(Json) 2123 + } 2124 + 2125 + // forks are repo records pointing back at a repo, so they get their own edge 2126 + // kind instead of a collection 2127 + async fn count_forks( 2128 + State(state): State<AppState>, 2129 + XrpcQuery(q): XrpcQuery<CountQuery>, 2130 + ) -> Result<Json<CountResponse>, XrpcError> { 2131 + let subject = parse_subject(&q.subject, FORK_SUBJECT_SHAPE)?; 2132 + let key = EdgeKey::new(nsid_static(REPO_SOURCE_EDGE_KIND), subject); 2133 + Ok(Json(CountResponse { 2134 + count: state.edges.count(&key), 2135 + distinct_authors: state.edges.count_distinct_authors(&key), 2136 + })) 2118 2137 } 2119 2138 2120 2139 async fn list_knots(
+70
bobbin/crates/xrpc/tests/aggregation.rs
··· 351 351 } 352 352 353 353 #[tokio::test] 354 + async fn count_forks_counts_repos_pointing_at_the_source() { 355 + let h = Harness::new().await; 356 + let subject = at("at://did:plc:abalone"); 357 + h.add_edge( 358 + &nsid("sh.tangled.repo.source"), 359 + &subject, 360 + &at("at://did:plc:nel/sh.tangled.repo/f1"), 361 + ); 362 + h.add_edge( 363 + &nsid("sh.tangled.repo.source"), 364 + &subject, 365 + &at("at://did:plc:olaren/sh.tangled.repo/f2"), 366 + ); 367 + // the source owner's other repos are not forks of it 368 + h.add_edge( 369 + &nsid("sh.tangled.repo"), 370 + &subject, 371 + &at("at://did:plc:nel/sh.tangled.repo/r1"), 372 + ); 373 + 374 + let app = router(h.state.clone()); 375 + let resp = app 376 + .oneshot(list_request( 377 + "sh.tangled.repo.countForks", 378 + subject.as_ref(), 379 + &[], 380 + )) 381 + .await 382 + .unwrap(); 383 + let (status, body) = json_response(resp).await; 384 + assert_eq!(status, StatusCode::OK); 385 + assert_eq!(body["count"], json!(2)); 386 + assert_eq!(body["distinctAuthors"], json!(2)); 387 + } 388 + 389 + #[tokio::test] 390 + async fn count_forks_of_an_unforked_repo_is_zero() { 391 + let h = Harness::new().await; 392 + let app = router(h.state.clone()); 393 + let resp = app 394 + .oneshot(list_request( 395 + "sh.tangled.repo.countForks", 396 + "did:plc:abalone", 397 + &[], 398 + )) 399 + .await 400 + .unwrap(); 401 + let (status, body) = json_response(resp).await; 402 + assert_eq!(status, StatusCode::OK); 403 + assert_eq!(body["count"], json!(0)); 404 + } 405 + 406 + #[tokio::test] 407 + async fn count_forks_rejects_an_at_uri_subject() { 408 + let h = Harness::new().await; 409 + let app = router(h.state.clone()); 410 + let resp = app 411 + .oneshot(list_request( 412 + "sh.tangled.repo.countForks", 413 + "at://did:plc:nel/sh.tangled.repo/core", 414 + &[], 415 + )) 416 + .await 417 + .unwrap(); 418 + let (status, body) = json_response(resp).await; 419 + assert_eq!(status, StatusCode::BAD_REQUEST); 420 + assert_eq!(body["error"], "InvalidRequest"); 421 + } 422 + 423 + #[tokio::test] 354 424 async fn list_items_stable_across_coverage_promotion() { 355 425 let h = Harness::new().await; 356 426 let subject = at("at://did:plc:abalone");
+39
lexicons/repo/countForks.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.countForks", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["subject"], 10 + "properties": { 11 + "subject": { 12 + "type": "string", 13 + "format": "did", 14 + "description": "Repo DID to count forks of." 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["count", "distinctAuthors"], 23 + "properties": { 24 + "count": { 25 + "type": "integer", 26 + "minimum": 0, 27 + "description": "Total number of matching records." 28 + }, 29 + "distinctAuthors": { 30 + "type": "integer", 31 + "minimum": 0, 32 + "description": "Number of distinct authors among the matching records." 33 + } 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }