diff --git a/bobbin/crates/edge-index/src/lib.rs b/bobbin/crates/edge-index/src/lib.rs index f37d2294..c1b58629 100644 --- a/bobbin/crates/edge-index/src/lib.rs +++ b/bobbin/crates/edge-index/src/lib.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use bobbin_runtime::RuntimeHasher; use bobbin_types::edges::{Edge, Record}; -use bobbin_types::ids::EdgeKey; +use bobbin_types::ids::{EdgeKey, SubjectRef}; use either::Either; use itertools::Itertools; use jacquard_common::DefaultStr; @@ -841,6 +841,58 @@ impl EdgeStore { }, ) } + fn distinct_authors(&self, sources: &Sources) -> u64 { + match sources { + Sources::Large(big) => big.authors.len() as u64, + Sources::Small(keys) => self.distinct_authors_small(keys), + } + } + + fn distinct_authors_since(&self, sources: &Sources, since_micros: u64) -> u64 { + sources + .directed(PageCursor::Start, SortDir::Asc) + .filter(|key| key.micros.0 >= since_micros) + .filter_map(|key| self.author_of(key.source)) + .collect::>() + .len() as u64 + } + + fn ranked_dids(&self, kind: &str, since_micros: Option, limit: usize) -> Vec { + let mut ranked = Vec::with_capacity(self.forward.len()); + self.forward.iter_sync(|key_id, sources| { + let Some(key) = self.keys.read_sync(key_id, |_, key| key.clone()) else { + return true; + }; + let SubjectRef::Did(did) = key.subject else { + return true; + }; + if key.kind.as_ref() != kind { + return true; + } + let count = since_micros.map_or_else( + || self.distinct_authors(sources), + |since| self.distinct_authors_since(sources, since), + ); + if count > 0 { + ranked.push((count, did)); + } + true + }); + ranked.sort_unstable_by(|(left_count, left_did), (right_count, right_did)| { + right_count + .cmp(left_count) + .then_with(|| left_did.as_str().cmp(right_did.as_str())) + }); + ranked.into_iter().take(limit).map(|(_, did)| did).collect() + } + + pub fn most_followed(&self, limit: usize) -> Vec { + self.ranked_dids("sh.tangled.graph.follow", None, limit) + } + + pub fn top_starred_since(&self, since_micros: u64, limit: usize) -> Vec { + self.ranked_dids("sh.tangled.feed.star", Some(since_micros), limit) + } pub fn count(&self, key: &EdgeKey) -> u64 { self.lookup_key(key) @@ -854,10 +906,8 @@ impl EdgeStore { pub fn count_distinct_authors(&self, key: &EdgeKey) -> u64 { self.lookup_key(key) .and_then(|id| { - self.forward.read_sync(&id, |_, sources| match sources { - Sources::Large(big) => big.authors.len() as u64, - Sources::Small(v) => self.distinct_authors_small(v), - }) + self.forward + .read_sync(&id, |_, sources| self.distinct_authors(sources)) }) .unwrap_or(0) } @@ -1472,6 +1522,19 @@ mod tests { } } + fn follow_edge_at( + source: AtUri, + subject: Did, + sort_micros: u64, + ) -> Edge { + Edge { + kind: nsid("sh.tangled.graph.follow"), + subject: SubjectRef::Did(subject), + source, + sort_micros, + } + } + fn shuffled_micros(i: usize) -> u64 { (i as u64).wrapping_mul(2_654_435_761) % 64 } @@ -2874,6 +2937,63 @@ mod tests { ); } + #[test] + fn most_followed_ranks_distinct_current_followers() { + let store = store(); + let alpha = did("did:plc:alpha"); + let beta = did("did:plc:beta"); + let gamma = did("did:plc:gamma"); + [ + ("did:plc:nel", "a1", alpha.clone()), + ("did:plc:nel", "a2", alpha.clone()), + ("did:plc:olaren", "a3", alpha.clone()), + ("did:plc:nel", "b1", beta.clone()), + ("did:plc:olaren", "b2", beta.clone()), + ("did:plc:teq", "b3", beta.clone()), + ("did:plc:nel", "g1", gamma.clone()), + ("did:plc:olaren", "g2", gamma.clone()), + ] + .into_iter() + .enumerate() + .for_each(|(index, (author, rkey, subject))| { + store.add(follow_edge_at( + at(&format!("at://{author}/sh.tangled.graph.follow/{rkey}")), + subject, + index as u64, + )); + }); + + assert_eq!(store.most_followed(3), vec![beta, alpha, gamma]); + } + + #[test] + fn top_starred_since_counts_distinct_recent_starrers() { + let store = store(); + let recent = did("did:plc:recent"); + let one = did("did:plc:one"); + let old = did("did:plc:old"); + [ + ("did:plc:nel", "r1", recent.clone(), 100), + ("did:plc:nel", "r2", recent.clone(), 110), + ("did:plc:olaren", "r3", recent.clone(), 120), + ("did:plc:teq", "o1", one.clone(), 130), + ("did:plc:nel", "x1", old.clone(), 1), + ("did:plc:olaren", "x2", old.clone(), 2), + ("did:plc:teq", "x3", old.clone(), 3), + ] + .into_iter() + .for_each(|(author, rkey, subject, micros)| { + store.add(star_edge_at( + at(&format!("at://{author}/sh.tangled.feed.star/{rkey}")), + subject, + micros, + )); + }); + + assert_eq!(store.top_starred_since(100, 5), vec![recent, one]); + assert_eq!(store.top_starred_since(100, 1).len(), 1); + } + #[test] fn author_refs_spill_preserves_distinct_count() { let store = store(); diff --git a/bobbin/crates/xrpc/src/actor.rs b/bobbin/crates/xrpc/src/actor.rs new file mode 100644 index 00000000..3b835cf0 --- /dev/null +++ b/bobbin/crates/xrpc/src/actor.rs @@ -0,0 +1,176 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::{Json, extract::State}; +use bobbin_types::sh_tangled::actor::get_trending::GetTrendingOutput; +use futures::stream::{self, StreamExt}; +use jacquard_common::types::did::Did; +use tokio::time::Instant; + +use crate::{ + AppState, FETCH_CONCURRENCY, XrpcError, + view::{build_profile_detailed, build_repo_view_basic}, +}; + +const OUTPUT_LIMIT: usize = 4; +// +1 are fallbacks in case a returned person is the viewer or a repo isnt resolvable +const PEOPLE_QUERY_LIMIT: usize = OUTPUT_LIMIT + 1; +const REPO_QUERY_LIMIT: usize = OUTPUT_LIMIT + 1; +const TRENDING_WINDOW: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const CACHE_TTL: Duration = Duration::from_secs(60 * 60); + +#[derive(Clone, Default)] +pub(crate) struct TrendingCandidates { + people: Vec, + repos: Vec, +} + +#[axum::debug_handler] +pub(crate) async fn get_trending( + State(state): State, +) -> Result, XrpcError> { + let permit = state.heavy_permit()?; + + let candidates = { + let mut cached = state.trending_cache.lock().await; + let now = Instant::now(); + if cached + .as_ref() + .is_none_or(|(refreshed_at, _)| now - *refreshed_at >= CACHE_TTL) + { + let candidates = TrendingCandidates { + people: state.edges.most_followed(PEOPLE_QUERY_LIMIT), + repos: state + .edges + .top_starred_since(trending_cutoff_micros(), REPO_QUERY_LIMIT), + }; + if state.coverage.snapshot().is_ready() { + *cached = Some((now, candidates.clone())); + } + candidates + } else { + cached + .as_ref() + .expect("fresh trending cache entry") + .1 + .clone() + } + }; + + let people = stream::iter(candidates.people) + .map({ + let state = &state; + move |did| async move { build_profile_detailed(state, None, &did).await } + }) + .buffered(FETCH_CONCURRENCY) + .collect() + .await; + + let hydrated = stream::iter(candidates.repos) + .map({ + let state = &state; + move |did| async move { + let result = build_repo_view_basic(state, None, &did).await; + (did, result) + } + }) + .buffered(FETCH_CONCURRENCY) + .collect::>() + .await; + let repos = hydrated + .into_iter() + .filter_map(|(did, result)| match result { + Ok(repo) => Some(repo), + Err(error) => { + tracing::warn!(repo_did = %did, %error, "skipping trending repo, hydration failed"); + None + } + }) + .take(OUTPUT_LIMIT) + .collect(); + + drop(permit); + Ok(Json(GetTrendingOutput { + people, + repos, + extra_data: None, + })) +} + +fn trending_cutoff_micros() -> u64 { + let elapsed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .saturating_sub(TRENDING_WINDOW); + elapsed + .as_secs() + .saturating_mul(1_000_000) + .saturating_add(elapsed.subsec_micros().into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bobbin_edge_index::EdgeStore; + use bobbin_runtime::RuntimeHasher; + use bobbin_types::{ + edges::Edge, + ids::{SubjectRef, nsid_static}, + }; + use jacquard_common::types::string::AtUri; + + fn did(value: &str) -> Did { + Did::new_owned(value).unwrap() + } + + #[test] + fn trending_candidates_are_ranked_from_the_edge_index() { + let edges = EdgeStore::new(RuntimeHasher::default()); + let alpha = did("did:plc:alpha"); + let beta = did("did:plc:beta"); + let compiler = did("did:plc:compiler"); + let editor = did("did:plc:editor"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros() as u64; + + [ + ("did:plc:a", "a1", alpha.clone()), + ("did:plc:b", "a2", alpha.clone()), + ("did:plc:c", "b1", beta.clone()), + ] + .into_iter() + .for_each(|(author, rkey, subject)| { + edges.add(Edge { + kind: nsid_static("sh.tangled.graph.follow"), + subject: SubjectRef::Did(subject), + source: AtUri::new_owned(format!("at://{author}/sh.tangled.graph.follow/{rkey}")) + .unwrap(), + sort_micros: now, + }); + }); + [ + ("did:plc:a", "c1", compiler.clone(), now), + ("did:plc:b", "c2", compiler.clone(), now), + ("did:plc:c", "e1", editor.clone(), now), + ] + .into_iter() + .for_each(|(author, rkey, subject, sort_micros)| { + edges.add(Edge { + kind: nsid_static("sh.tangled.feed.star"), + subject: SubjectRef::Did(subject), + source: AtUri::new_owned(format!("at://{author}/sh.tangled.feed.star/{rkey}")) + .unwrap(), + sort_micros, + }); + }); + + let candidates = TrendingCandidates { + people: edges.most_followed(PEOPLE_QUERY_LIMIT), + repos: edges.top_starred_since(trending_cutoff_micros(), REPO_QUERY_LIMIT), + }; + + assert_eq!(candidates.people, vec![alpha, beta]); + assert_eq!(candidates.repos, vec![compiler, editor]); + } +} diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index ccc8d08e..b9f89481 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -98,12 +98,14 @@ use serde::{Deserialize, Serialize}; use std::convert::Infallible; use std::time::Duration; use thiserror::Error; +use tokio::time::Instant; use url::form_urlencoded; use tower_http::classify::ServerErrorsFailureClass; use tower_http::trace::{DefaultMakeSpan, OnFailure, OnResponse, TraceLayer}; use tracing::{Level, Span}; +mod actor; mod backpressure; mod client_address; mod enrich; @@ -155,6 +157,7 @@ pub struct AppState { pub settlements: Arc, service_auth_config: service_auth::ServiceAuthConfig>, enrich_router: Arc>, + trending_cache: Arc>>, } impl AppState { @@ -199,6 +202,7 @@ impl AppState { directory, ), enrich_router: Arc::new(std::sync::OnceLock::new()), + trending_cache: Arc::new(tokio::sync::Mutex::new(None)), } } @@ -292,6 +296,10 @@ pub fn router(state: AppState) -> Router { .route("/xrpc/sh.tangled.repo.getRepoByName", get(get_repo_by_name)) .route("/xrpc/sh.tangled.actor.getProfile", get(get_profile)) .route("/xrpc/sh.tangled.actor.getProfiles", get(get_profiles)) + .route( + "/xrpc/sh.tangled.actor.getTrending", + get(actor::get_trending), + ) .route("/xrpc/sh.tangled.repo.getIssue", get(get_issue)) .route("/xrpc/sh.tangled.repo.getIssues", get(get_issues)) .route("/xrpc/sh.tangled.repo.getPull", get(get_pull)) diff --git a/bobbin/crates/xrpc/src/view.rs b/bobbin/crates/xrpc/src/view.rs index fce4edd2..f9ab4ab6 100644 --- a/bobbin/crates/xrpc/src/view.rs +++ b/bobbin/crates/xrpc/src/view.rs @@ -35,6 +35,30 @@ pub(crate) async fn build_profile_basic( .build() } +pub(crate) async fn build_profile_detailed( + state: &AppState, + viewer: Option<&Did>, + did: &Did, +) -> actor::ProfileViewDetailed { + let basic = build_profile_basic(state, viewer, did).await; + let followers = state.edges.count(&EdgeKey::new( + nsid_static(FollowRecord::NSID), + SubjectRef::Did(did.clone()), + )) as i64; + let follows = state.edges.count(&EdgeKey::new( + nsid_static("sh.tangled.graph.follow.by"), + SubjectRef::Did(did.clone()), + )) as i64; + actor::ProfileViewDetailed::new() + .did(basic.did) + .handle(basic.handle) + .followers_count(followers) + .follows_count(follows) + .maybe_avatar(basic.avatar) + .maybe_viewer(basic.viewer) + .build() +} + fn build_actor_viewer_state(edges: &EdgeStore, viewer: &Did, subject: &Did) -> actor::ViewerState { let following = edges.viewer_source( &EdgeKey::new( diff --git a/bobbin/crates/xrpc/tests/trending.rs b/bobbin/crates/xrpc/tests/trending.rs new file mode 100644 index 00000000..56142410 --- /dev/null +++ b/bobbin/crates/xrpc/tests/trending.rs @@ -0,0 +1,249 @@ +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::body::{Body, to_bytes}; +use bobbin_edge_index::{CoverageWatch, EdgeStore, IssueStateKind, PullStatusKind, StateIndex}; +use bobbin_knot_proxy::{KnotHttpConfig, KnotProxy, KnotProxyConfig}; +use bobbin_record_lru::{CacheCapacity, LruRecordStore}; +use bobbin_resolver::RepoIdResolver; +use bobbin_runtime::{RuntimeHasher, SystemClock}; +use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; +use bobbin_slingshot_client::SlingshotClient; +use bobbin_types::edges::Edge; +use bobbin_types::ids::SubjectRef; +use bobbin_xrpc::{AppState, router}; +use http::{Request, StatusCode}; +use jacquard_common::DefaultStr; +use jacquard_common::types::did::Did; +use jacquard_common::types::nsid::Nsid; +use jacquard_common::types::recordkey::Rkey; +use jacquard_common::types::string::AtUri; +use serde_json::{Value, json}; +use tower::ServiceExt; +use url::Url; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i"; + +fn at(value: &str) -> AtUri { + AtUri::new_owned(value).unwrap() +} + +fn did(value: &str) -> Did { + Did::new_owned(value).unwrap() +} + +fn nsid(value: &'static str) -> Nsid { + Nsid::new_static(value).unwrap() +} + +fn rkey(value: &str) -> Rkey { + Rkey::new_owned(value).unwrap() +} + +fn now_micros() -> u64 { + let elapsed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + elapsed + .as_secs() + .saturating_mul(1_000_000) + .saturating_add(elapsed.subsec_micros().into()) +} + +struct Harness { + server: MockServer, + edges: Arc, + resolver: Arc, + state: AppState, +} + +impl Harness { + async fn new() -> Self { + let server = MockServer::start().await; + let edges = Arc::new(EdgeStore::new(RuntimeHasher::default())); + let resolver = Arc::new(RepoIdResolver::detached(RuntimeHasher::default())); + let state = AppState::new( + Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), + SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(), + edges.clone(), + Arc::new(StateIndex::::new(RuntimeHasher::default())), + Arc::new(StateIndex::::new(RuntimeHasher::default())), + Arc::new(CoverageWatch::new()), + Arc::new( + KnotProxy::new( + KnotProxyConfig::default(), + KnotHttpConfig::default(), + Arc::new(SystemClock::new()), + RuntimeHasher::default(), + ) + .unwrap(), + ), + Arc::new( + SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), + ) as Arc, + resolver.clone(), + Arc::new(bobbin_xrpc::default_directory()), + ); + Self { + server, + edges, + resolver, + state, + } + } + + fn add_edge(&self, kind: &'static str, subject: Did, source: &str, micros: u64) { + self.edges.add(Edge { + kind: nsid(kind), + subject: SubjectRef::Did(subject), + source: at(source), + sort_micros: micros, + }); + } + + async fn mount_repo( + &self, + owner: Did, + record_key: Rkey, + repo_did: Did, + name: &str, + ) { + self.resolver + .observe( + owner.clone(), + record_key.clone(), + Some(repo_did.clone()), + Some(name.into()), + ) + .await; + let uri = format!( + "at://{}/sh.tangled.repo/{}", + owner.as_ref(), + record_key.as_ref() + ); + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .and(query_param("repo", owner.as_ref())) + .and(query_param("collection", "sh.tangled.repo")) + .and(query_param("rkey", record_key.as_ref())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": uri, + "cid": CID, + "value": { + "$type": "sh.tangled.repo", + "name": name, + "knot": "knot.example.com", + "repoDid": repo_did.as_ref(), + "createdAt": "2026-08-01T00:00:00Z" + } + }))) + .mount(&self.server) + .await; + } +} + +#[tokio::test] +async fn get_trending_returns_fixed_viewer_neutral_candidates() { + let harness = Harness::new().await; + let viewer = did("did:plc:viewer"); + let popular = did("did:plc:popular"); + let second = did("did:plc:second"); + let compiler = did("did:plc:compiler"); + let editor = did("did:plc:editor"); + let stale = did("did:plc:stale"); + let now = now_micros(); + + [ + ("did:plc:a", "p1", popular.clone()), + ("did:plc:b", "p2", popular.clone()), + ("did:plc:viewer", "p3", popular.clone()), + ("did:plc:c", "s1", second.clone()), + ("did:plc:a", "v1", viewer.clone()), + ("did:plc:b", "v2", viewer.clone()), + ("did:plc:c", "v3", viewer.clone()), + ("did:plc:d", "v4", viewer.clone()), + ] + .into_iter() + .for_each(|(author, key, subject)| { + harness.add_edge( + "sh.tangled.graph.follow", + subject, + &format!("at://{author}/sh.tangled.graph.follow/{key}"), + now, + ); + }); + + [ + ("did:plc:viewer", "c1", compiler.clone(), now), + ("did:plc:a", "c2", compiler.clone(), now), + ("did:plc:b", "c3", compiler.clone(), now), + ("did:plc:c", "e1", editor.clone(), now), + ( + "did:plc:a", + "x1", + stale.clone(), + now - 8 * 24 * 60 * 60 * 1_000_000, + ), + ] + .into_iter() + .for_each(|(author, key, subject, micros)| { + harness.add_edge( + "sh.tangled.feed.star", + subject, + &format!("at://{author}/sh.tangled.feed.star/{key}"), + micros, + ); + }); + + harness + .mount_repo( + did("did:plc:owner"), + rkey("compiler"), + compiler.clone(), + "compiler", + ) + .await; + harness + .mount_repo( + did("did:plc:maker"), + rkey("editor"), + editor.clone(), + "editor", + ) + .await; + + let response = router(harness.state) + .oneshot( + Request::builder() + .uri("/xrpc/sh.tangled.actor.getTrending") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 1 << 20).await.unwrap()).unwrap(); + + let people = body["people"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| item["did"].as_str()) + .collect::>(); + assert_eq!( + people, + vec![viewer.as_ref(), popular.as_ref(), second.as_ref()] + ); + assert_eq!(body["people"][1]["followersCount"], 3); + assert!(body["people"][0]["viewer"].is_null()); + let repos = body["repos"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| item["did"].as_str()) + .collect::>(); + assert_eq!(repos, vec![compiler.as_ref(), editor.as_ref()]); + assert_eq!(body["repos"][0]["starCount"], 3); + assert!(body["repos"][0]["viewer"].is_null()); +} diff --git a/lexicons/actor/getTrending.json b/lexicons/actor/getTrending.json new file mode 100644 index 00000000..c9580250 --- /dev/null +++ b/lexicons/actor/getTrending.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "sh.tangled.actor.getTrending", + "defs": { + "main": { + "type": "query", + "description": "Get trending people and repositories.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["people", "repos"], + "properties": { + "people": { + "type": "array", + "maxLength": 5, + "items": { + "type": "ref", + "ref": "sh.tangled.actor.defs#profileViewDetailed" + } + }, + "repos": { + "type": "array", + "maxLength": 4, + "items": { + "type": "ref", + "ref": "sh.tangled.repo.defs#repoViewBasic" + } + } + } + } + } + } + } +} diff --git a/web/src/lib/api/lexicons/index.ts b/web/src/lib/api/lexicons/index.ts index 9ac1d9c0..972a942a 100644 --- a/web/src/lib/api/lexicons/index.ts +++ b/web/src/lib/api/lexicons/index.ts @@ -1,6 +1,7 @@ export * as ShTangledActorDefs from "./types/sh/tangled/actor/defs.js"; export * as ShTangledActorGetProfile from "./types/sh/tangled/actor/getProfile.js"; export * as ShTangledActorGetProfiles from "./types/sh/tangled/actor/getProfiles.js"; +export * as ShTangledActorGetTrending from "./types/sh/tangled/actor/getTrending.js"; export * as ShTangledActorProfile from "./types/sh/tangled/actor/profile.js"; export * as ShTangledBobbinAwaitRecord from "./types/sh/tangled/bobbin/awaitRecord.js"; export * as ShTangledCiCancelPipeline from "./types/sh/tangled/ci/cancelPipeline.js"; @@ -138,7 +139,6 @@ export * as ShTangledRepoDelete from "./types/sh/tangled/repo/delete.js"; export * as ShTangledRepoDeleteBranch from "./types/sh/tangled/repo/deleteBranch.js"; export * as ShTangledRepoDescribeRepo from "./types/sh/tangled/repo/describeRepo.js"; export * as ShTangledRepoDiff from "./types/sh/tangled/repo/diff.js"; -export * as ShTangledRepoForkStatus from "./types/sh/tangled/repo/forkStatus.js"; export * as ShTangledRepoForkSync from "./types/sh/tangled/repo/forkSync.js"; export * as ShTangledRepoGetDefaultBranch from "./types/sh/tangled/repo/getDefaultBranch.js"; export * as ShTangledRepoGetIssue from "./types/sh/tangled/repo/getIssue.js"; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/actor/getTrending.ts b/web/src/lib/api/lexicons/types/sh/tangled/actor/getTrending.ts new file mode 100644 index 00000000..d49e14c0 --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/actor/getTrending.ts @@ -0,0 +1,47 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ShTangledActorDefs from "./defs.js"; +import * as ShTangledRepoDefs from "../repo/defs.js"; + +const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.actor.getTrending", { + params: null, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * @maxLength 5 + */ + get people() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(ShTangledActorDefs.profileViewDetailedSchema), + [/*#__PURE__*/ v.arrayLength(0, 5)], + ); + }, + /** + * @maxLength 4 + */ + get repos() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(ShTangledRepoDefs.repoViewBasicSchema), + [/*#__PURE__*/ v.arrayLength(0, 4)], + ); + }, + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "sh.tangled.actor.getTrending": mainSchema; + } +}