From a9b820e5ac9114fab3f4b7af53112b8ab427a2fb Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 21 Jul 2026 00:53:58 +0300 Subject: [PATCH] bobbin/edge-index,bobbin/xrpc: add viewer_source edge lookup + graph.getFollow, feed.getStar Signed-off-by: dawn --- bobbin/crates/edge-index/src/lib.rs | 25 ++++++ bobbin/crates/resolver/src/legacy_upgrade.rs | 2 - bobbin/crates/xrpc/src/lib.rs | 42 ++++++++++ bobbin/crates/xrpc/tests/aggregation.rs | 79 +++++++++++++++++++ lexicons/feed/getStar.json | 38 +++++++++ lexicons/graph/getFollow.json | 39 +++++++++ web/src/lib/api/lexicons/index.ts | 2 + .../lexicons/types/sh/tangled/feed/getStar.ts | 40 ++++++++++ .../types/sh/tangled/graph/getFollow.ts | 40 ++++++++++ 9 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 lexicons/feed/getStar.json create mode 100644 lexicons/graph/getFollow.json create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/feed/getStar.ts create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/graph/getFollow.ts diff --git a/bobbin/crates/edge-index/src/lib.rs b/bobbin/crates/edge-index/src/lib.rs index b81240f3..32f0354e 100644 --- a/bobbin/crates/edge-index/src/lib.rs +++ b/bobbin/crates/edge-index/src/lib.rs @@ -650,6 +650,31 @@ impl EdgeStore { }) .unwrap_or(0) } + /// answers "did the viewer star/follow/etc. this subject, and with what rkey" + pub fn viewer_source(&self, key: &EdgeKey, viewer: &str) -> Option> { + let author_spur = self.did_interner.get(viewer)?; + let author_id = AuthorId::from_spur(author_spur); + let key_id = self.lookup_key(key)?; + + self.forward + .read_sync(&key_id, |_, sources| { + // large buckets track authors, so a missing author fast-fails the scan + if let Sources::Large(big) = sources + && !big.authors.contains_key(&author_id) + { + return None; + } + sources + .directed(PageCursor::Start, SortDir::Desc) + .find_map(|bucket| { + let spur = bucket.source.to_spur()?; + let stored = self.source_interner.try_resolve(&spur)?; + let uri = AtUri::new_owned(self.decode_source(stored)?).ok()?; + (source_authority_did(&uri) == Some(viewer)).then_some(uri) + }) + }) + .flatten() + } pub fn sources_for(&self, key: &EdgeKey) -> Vec> { self.lookup_key(key) diff --git a/bobbin/crates/resolver/src/legacy_upgrade.rs b/bobbin/crates/resolver/src/legacy_upgrade.rs index 91bc4f93..d54f7ece 100644 --- a/bobbin/crates/resolver/src/legacy_upgrade.rs +++ b/bobbin/crates/resolver/src/legacy_upgrade.rs @@ -448,8 +448,6 @@ fn upgrade_ref_update(l: LegacyRefUpdate) -> RefUpdate { push_options: None, r#ref: l.r#ref, repo: l.repo_did, - changed_files: None, - push_options: None, extra_data: l.extra_data, } } diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index fa1814f6..28612da2 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -172,8 +172,10 @@ pub fn router(state: AppState) -> Router { .route("/xrpc/sh.tangled.repo.getPulls", get(get_pulls)) .route("/xrpc/sh.tangled.feed.listStars", get(list_stars)) .route("/xrpc/sh.tangled.feed.countStars", get(count_stars)) + .route("/xrpc/sh.tangled.feed.getStar", get(get_star)) .route("/xrpc/sh.tangled.graph.listFollows", get(list_follows)) .route("/xrpc/sh.tangled.graph.countFollows", get(count_follows)) + .route("/xrpc/sh.tangled.graph.getFollow", get(get_follow)) .route("/xrpc/sh.tangled.repo.listIssues", get(list_issues)) .route("/xrpc/sh.tangled.repo.countIssues", get(count_issues)) .route("/xrpc/sh.tangled.repo.listPulls", get(list_pulls)) @@ -633,6 +635,12 @@ struct CountQuery { subject: SubjectQuery, } +#[derive(Debug, Deserialize)] +struct GetEdgeQuery { + actor: Did, + subject: SubjectQuery, +} + #[derive(Debug, Deserialize)] struct SearchQueryParams { q: String, @@ -852,6 +860,12 @@ struct CountResponse { distinct_authors: u64, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EdgeUriResponse { + uri: AtUri, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SearchHitView { @@ -1811,6 +1825,20 @@ fn count_for( }) } +// does `actor` have an edge of this kind pointing at `subject`, returns its own uri +fn get_for( + state: &AppState, + q: GetEdgeQuery, +) -> Result { + let subject = parse_subject(&q.subject, R::SHAPE)?; + let key = EdgeKey::new(nsid_static(R::NSID), subject); + let uri = state + .edges + .viewer_source(&key, q.actor.as_str()) + .ok_or(XrpcError::NotFound)?; + Ok(EdgeUriResponse { uri }) +} + fn mirror_edge_page( state: &AppState, q: &TypedListQuery, @@ -1876,6 +1904,13 @@ async fn count_stars( count_for::(&state, q).map(Json) } +async fn get_star( + State(state): State, + XrpcQuery(q): XrpcQuery, +) -> Result, XrpcError> { + get_for::(&state, q).map(Json) +} + async fn list_follows( State(state): State, XrpcQuery(q): XrpcQuery>, @@ -1890,6 +1925,13 @@ async fn count_follows( count_for::(&state, q).map(Json) } +async fn get_follow( + State(state): State, + XrpcQuery(q): XrpcQuery, +) -> Result, XrpcError> { + get_for::(&state, q).map(Json) +} + async fn list_issues( State(state): State, XrpcQuery(q): XrpcQuery>, diff --git a/bobbin/crates/xrpc/tests/aggregation.rs b/bobbin/crates/xrpc/tests/aggregation.rs index 31a5dedc..8c61e077 100644 --- a/bobbin/crates/xrpc/tests/aggregation.rs +++ b/bobbin/crates/xrpc/tests/aggregation.rs @@ -605,6 +605,85 @@ async fn list_follows_subject_is_followee_did() { assert_eq!(items[0]["value"]["subject"], followee.as_ref()); } +#[tokio::test] +async fn get_follow_returns_uri_when_present_and_404_otherwise() { + let h = Harness::new().await; + let followee = did("did:plc:bailey"); + let subject = at(&format!("at://{}", followee.as_ref())); + h.add_edge( + &nsid("sh.tangled.graph.follow"), + &subject, + &at("at://did:plc:nel/sh.tangled.graph.follow/f1"), + ); + + let app = router(h.state.clone()); + let (status, body) = json_response( + app.clone() + .oneshot(list_request( + "sh.tangled.graph.getFollow", + followee.as_ref(), + &[("actor", "did:plc:nel")], + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["uri"], "at://did:plc:nel/sh.tangled.graph.follow/f1"); + + // different actor never followed them, so this is 404 not a zero-ish success + let (status, _) = json_response( + app.oneshot(list_request( + "sh.tangled.graph.getFollow", + followee.as_ref(), + &[("actor", "did:plc:someoneelse")], + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn get_star_returns_uri_when_present_and_404_otherwise() { + let h = Harness::new().await; + let repo_did = did("did:plc:limpet"); + let subject = at(&format!("at://{}", repo_did.as_ref())); + h.add_edge( + &nsid("sh.tangled.feed.star"), + &subject, + &at("at://did:plc:nel/sh.tangled.feed.star/s1"), + ); + + let app = router(h.state.clone()); + let (status, body) = json_response( + app.clone() + .oneshot(list_request( + "sh.tangled.feed.getStar", + repo_did.as_ref(), + &[("actor", "did:plc:nel")], + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["uri"], "at://did:plc:nel/sh.tangled.feed.star/s1"); + + let (status, _) = json_response( + app.oneshot(list_request( + "sh.tangled.feed.getStar", + repo_did.as_ref(), + &[("actor", "did:plc:someoneelse")], + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + #[tokio::test] async fn upstream_failure_during_hydration_drops_only_that_item() { let h = Harness::new().await; diff --git a/lexicons/feed/getStar.json b/lexicons/feed/getStar.json new file mode 100644 index 00000000..d29578e8 --- /dev/null +++ b/lexicons/feed/getStar.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "sh.tangled.feed.getStar", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["actor", "subject"], + "properties": { + "actor": { + "type": "string", + "format": "did", + "description": "DID of the potential stargazer." + }, + "subject": { + "type": "string", + "description": "Repo DID to check." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Uri of actor's star record for subject." + } + } + } + } + } + } +} diff --git a/lexicons/graph/getFollow.json b/lexicons/graph/getFollow.json new file mode 100644 index 00000000..3e40a3a0 --- /dev/null +++ b/lexicons/graph/getFollow.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "sh.tangled.graph.getFollow", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["actor", "subject"], + "properties": { + "actor": { + "type": "string", + "format": "did", + "description": "DID of the potential follower." + }, + "subject": { + "type": "string", + "format": "did", + "description": "Followee DID to check." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Uri of actor's follow record for subject." + } + } + } + } + } + } +} diff --git a/web/src/lib/api/lexicons/index.ts b/web/src/lib/api/lexicons/index.ts index 2a4fab1a..2fa56575 100644 --- a/web/src/lib/api/lexicons/index.ts +++ b/web/src/lib/api/lexicons/index.ts @@ -7,6 +7,7 @@ export * as ShTangledCiSubscribePipelineLogs from "./types/sh/tangled/ci/subscri export * as ShTangledCiTrigger from "./types/sh/tangled/ci/trigger.js"; export * as ShTangledCiTriggerPipeline from "./types/sh/tangled/ci/triggerPipeline.js"; export * as ShTangledFeedComment from "./types/sh/tangled/feed/comment.js"; +export * as ShTangledFeedGetStar from "./types/sh/tangled/feed/getStar.js"; export * as ShTangledFeedListComments from "./types/sh/tangled/feed/listComments.js"; export * as ShTangledFeedListCommentsBy from "./types/sh/tangled/feed/listCommentsBy.js"; export * as ShTangledFeedListReactions from "./types/sh/tangled/feed/listReactions.js"; @@ -35,6 +36,7 @@ export * as ShTangledGitTempListCommits from "./types/sh/tangled/git/temp/listCo export * as ShTangledGitTempListLanguages from "./types/sh/tangled/git/temp/listLanguages.js"; export * as ShTangledGitTempListTags from "./types/sh/tangled/git/temp/listTags.js"; export * as ShTangledGraphFollow from "./types/sh/tangled/graph/follow.js"; +export * as ShTangledGraphGetFollow from "./types/sh/tangled/graph/getFollow.js"; export * as ShTangledGraphListFollows from "./types/sh/tangled/graph/listFollows.js"; export * as ShTangledGraphListFollowsBy from "./types/sh/tangled/graph/listFollowsBy.js"; export * as ShTangledGraphListVouches from "./types/sh/tangled/graph/listVouches.js"; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/feed/getStar.ts b/web/src/lib/api/lexicons/types/sh/tangled/feed/getStar.ts new file mode 100644 index 00000000..7422ef22 --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/feed/getStar.ts @@ -0,0 +1,40 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.feed.getStar", { + params: /*#__PURE__*/ v.object({ + /** + * DID of the potential stargazer. + */ + actor: /*#__PURE__*/ v.didString(), + /** + * Repo DID to check. + */ + subject: /*#__PURE__*/ v.string(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * Uri of actor's star record for subject. + */ + uri: /*#__PURE__*/ v.resourceUriString(), + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "sh.tangled.feed.getStar": mainSchema; + } +} diff --git a/web/src/lib/api/lexicons/types/sh/tangled/graph/getFollow.ts b/web/src/lib/api/lexicons/types/sh/tangled/graph/getFollow.ts new file mode 100644 index 00000000..5dc51a53 --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/graph/getFollow.ts @@ -0,0 +1,40 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.graph.getFollow", { + params: /*#__PURE__*/ v.object({ + /** + * DID of the potential follower. + */ + actor: /*#__PURE__*/ v.didString(), + /** + * Followee DID to check. + */ + subject: /*#__PURE__*/ v.didString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * Uri of actor's follow record for subject. + */ + uri: /*#__PURE__*/ v.resourceUriString(), + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "sh.tangled.graph.getFollow": mainSchema; + } +} -- 2.51.2