From bca4a0e08f6a392f0d1458568ec65d5410d3fdbf Mon Sep 17 00:00:00 2001 From: dawn Date: Sat, 19 Sep 2026 17:07:53 +0300 Subject: [PATCH] lexicons,bobbin/xrpc,web/api: batch listNetworkVouches over many actors Signed-off-by: dawn --- api/tangled/graphlistNetworkVouches.go | 15 +- bobbin/crates/xrpc/src/filter.rs | 16 +- bobbin/crates/xrpc/src/lib.rs | 360 +++++++++++++++--- lexicons/graph/listNetworkVouches.json | 29 +- web/src/lib/api/graph.test.ts | 72 ++++ web/src/lib/api/graph.ts | 52 ++- .../sh/tangled/graph/listNetworkVouches.ts | 16 +- web/src/lib/components/profile/pages.ts | 11 +- 8 files changed, 476 insertions(+), 95 deletions(-) create mode 100644 web/src/lib/api/graph.test.ts diff --git a/api/tangled/graphlistNetworkVouches.go b/api/tangled/graphlistNetworkVouches.go index 86588e02e..18f3b2a7f 100644 --- a/api/tangled/graphlistNetworkVouches.go +++ b/api/tangled/graphlistNetworkVouches.go @@ -16,8 +16,10 @@ const ( // GraphListNetworkVouches_ListItem is a "listItem" in the sh.tangled.graph.listNetworkVouches schema. type GraphListNetworkVouches_ListItem struct { - Cid *string `json:"cid,omitempty" cborgen:"cid,omitempty"` - Uri string `json:"uri" cborgen:"uri"` + // actor: The requested actor this vouch involves. + Actor string `json:"actor" cborgen:"actor"` + Cid *string `json:"cid,omitempty" cborgen:"cid,omitempty"` + Uri string `json:"uri" cborgen:"uri"` // value: Embedded sh.tangled.graph.vouch record Value *util.LexiconTypeDecoder `json:"value" cborgen:"value"` } @@ -30,13 +32,14 @@ type GraphListNetworkVouches_Output struct { // GraphListNetworkVouches calls the XRPC method "sh.tangled.graph.listNetworkVouches". // -// actor: The DID of the profile whose network vouches to list. -// cursor: Pagination cursor -func GraphListNetworkVouches(ctx context.Context, c util.LexClient, actor string, cursor string, limit int64) (*GraphListNetworkVouches_Output, error) { +// actors: DIDs of the profiles whose network vouches to list. At most 50 per request. +// cursor: Pagination cursor. Only accepted when a single actor is requested. +// limit: Maximum number of vouches per actor. +func GraphListNetworkVouches(ctx context.Context, c util.LexClient, actors []string, cursor string, limit int64) (*GraphListNetworkVouches_Output, error) { var out GraphListNetworkVouches_Output params := map[string]interface{}{} - params["actor"] = actor + params["actors"] = actors if cursor != "" { params["cursor"] = cursor } diff --git a/bobbin/crates/xrpc/src/filter.rs b/bobbin/crates/xrpc/src/filter.rs index 16cd3f102..403afd43e 100644 --- a/bobbin/crates/xrpc/src/filter.rs +++ b/bobbin/crates/xrpc/src/filter.rs @@ -165,16 +165,14 @@ impl VouchNetworkFilter { Self { network } } - pub fn contains(&self, did: &str) -> bool { - self.network.contains(did) - } - - pub fn is_empty(&self) -> bool { - self.network.is_empty() - } - - pub fn matches(&self, item: &EdgeItem) -> bool { + pub fn matches_author(&self, item: &EdgeItem) -> bool { crate::source_authority_did(&item.uri) .is_some_and(|did| self.network.contains(did.as_ref())) } + + pub fn matches_subject(&self, item: &EdgeItem) -> bool { + item.uri + .rkey() + .is_some_and(|rkey| self.network.contains(rkey.as_ref())) + } } diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index 6e576b7a0..ae5c1292c 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -53,7 +53,6 @@ use bobbin_types::sh_tangled::feed::comment::{ use bobbin_types::sh_tangled::feed::reaction::{Reaction, ReactionRecord}; use bobbin_types::sh_tangled::feed::star::{Star, StarRecord}; use bobbin_types::sh_tangled::graph::follow::{Follow, FollowRecord}; -use bobbin_types::sh_tangled::graph::list_network_vouches::ListNetworkVouches; use bobbin_types::sh_tangled::graph::vouch::{Vouch, VouchRecord}; use bobbin_types::sh_tangled::knot::member::{ Member as KnotMember, MemberRecord as KnotMemberRecord, @@ -1060,6 +1059,14 @@ struct RecordView { value: V, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct NetworkVouchItem { + actor: Did, + #[serde(flatten)] + view: RecordView, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CollaboratorListItem { @@ -1919,17 +1926,18 @@ where .try_filter_map(|view| async move { Ok(view) }) } -fn hydrate_record_stream( +fn hydrate_keyed_record_stream( state: &AppState, nsid: Nsid, - items: Vec, + items: Vec<(K, EdgeItem)>, provenance: HitProvenance, -) -> impl Stream, XrpcError>> + Send + 'static +) -> impl Stream), XrpcError>> + Send + 'static where + K: Send + 'static, V: serde::de::DeserializeOwned + Serialize + NormalizeRepoRefs + Send + 'static, { let owned = state.clone(); - hydrate_stream(items, move |item| { + hydrate_stream(items, move |(key, item)| { let owned = owned.clone(); let nsid = nsid.clone(); async move { @@ -1944,10 +1952,29 @@ where owned.edges.remove_source(&uri); } drop_unhydratable(provenance, &nsid, &uri, result) + .map(|view| view.map(|view| (key, view))) } }) } +fn hydrate_record_stream( + state: &AppState, + nsid: Nsid, + items: Vec, + provenance: HitProvenance, +) -> impl Stream, XrpcError>> + Send + 'static +where + V: serde::de::DeserializeOwned + Serialize + NormalizeRepoRefs + Send + 'static, +{ + hydrate_keyed_record_stream( + state, + nsid, + items.into_iter().map(|item| ((), item)).collect(), + provenance, + ) + .map_ok(|(_, view)| view) +} + enum PagePhase { Head, Body { first: bool }, @@ -2582,14 +2609,77 @@ async fn count_public_keys( count_for::(&state, q).map(Json) } -fn vouch_network(state: &AppState, actor: &SubjectRef) -> std::collections::HashSet { - let vouch_by = nsid_static("sh.tangled.graph.vouch.by"); - state - .edges - .sources_for(&EdgeKey::new(vouch_by, actor.clone())) +struct NetworkVouchQuery { + actors: Vec>, + start: PageCursor, + limit: PageLimit, +} + +fn one_query_value(query: Option<&str>, key: &str) -> Result, XrpcError> { + let values = collect_repeated(query, key); + match values.as_slice() { + [] => Ok(None), + [value] => Ok(Some(value.clone())), + _ => Err(XrpcError::InvalidParams(format!( + "{key} may only be specified once" + ))), + } +} + +fn parse_network_vouch_query(query: Option<&str>) -> Result { + let actors = collect_repeated(query, "actors"); + if actors.is_empty() { + return Err(XrpcError::InvalidParams( + "at least one actor required".into(), + )); + } + if actors.len() > BULK_LIMIT { + return Err(XrpcError::InvalidParams(format!( + "at most {BULK_LIMIT} actors per request" + ))); + } + let actors = actors + .iter() + .map(|actor| { + Did::::new_owned(actor) + .map_err(|_| XrpcError::InvalidParams(format!("invalid did: {actor}"))) + }) + .collect::, _>>()?; + let mut seen = std::collections::HashSet::new(); + let actors = actors + .into_iter() + .filter(|actor| seen.insert(actor.as_ref().to_owned())) + .collect::>(); + let cursor = one_query_value(query, "cursor")?; + if actors.len() > 1 && cursor.is_some() { + return Err(XrpcError::InvalidParams( + "cursor requires exactly one actor".into(), + )); + } + let limit = one_query_value(query, "limit")? + .map(|limit| { + limit + .parse::() + .map_err(|_| XrpcError::InvalidParams(format!("invalid limit: {limit}"))) + }) + .transpose()?; + Ok(NetworkVouchQuery { + actors, + start: parse_vouch_cursor(cursor.as_deref())?, + limit: parse_limit(limit)?, + }) +} + +fn vouch_network(edges: &EdgeStore, actor: &Did) -> std::collections::HashSet { + edges + .sources_for(&EdgeKey::new( + nsid_static("sh.tangled.graph.vouch.by"), + SubjectRef::Did(actor.clone()), + )) .iter() .filter_map(|uri| Did::::new_owned(uri.rkey()?.as_ref()).ok()) - .map(|did| did.as_ref().to_string()) + .map(|did| did.as_ref().to_owned()) + .chain(std::iter::once(actor.as_ref().to_owned())) .collect() } @@ -2686,6 +2776,56 @@ fn paginate_vouches( (items, next) } +fn network_vouch_page( + edges: &EdgeStore, + actor: &Did, + network: &VouchNetworkFilter, + start: PageCursor, + limit: PageLimit, +) -> (Vec, Option) { + let received = vouch_items( + edges, + EdgeKey::new( + nsid_static("sh.tangled.graph.vouch"), + SubjectRef::Did(actor.clone()), + ), + VouchList::Received, + start, + ) + .filter(|item| network.matches_author(item)); + let sent = vouch_items( + edges, + EdgeKey::new( + nsid_static("sh.tangled.graph.vouch.by"), + SubjectRef::Did(actor.clone()), + ), + VouchList::Sent, + start, + ) + .filter(|item| network.matches_subject(item)); + paginate_vouches(merge_vouches(received, sent), limit) +} + +fn network_vouch_pages( + edges: &EdgeStore, + actors: &[Did], + network: &VouchNetworkFilter, + start: PageCursor, + limit: PageLimit, +) -> (Vec<(Did, EdgeItem)>, Option) { + let single = actors.len() == 1; + let mut next_cursor = None; + let mut tagged = Vec::new(); + for actor in actors { + let (items, cursor) = network_vouch_page(edges, actor, network, start, limit); + tagged.extend(items.into_iter().map(|item| (actor.clone(), item))); + if single { + next_cursor = cursor; + } + } + (tagged, next_cursor) +} + #[cfg(test)] mod vouch_pagination_tests { use std::collections::HashSet; @@ -2852,60 +2992,176 @@ mod vouch_pagination_tests { PageCursor::After(PageToken::new(42, 0)), ); } + + fn vouch_edge( + kind: &'static str, + index_subject: &Did, + author: &Did, + record_subject: &Did, + micros: u64, + ) -> Edge { + Edge { + kind: nsid_static(kind), + subject: SubjectRef::Did(index_subject.clone()), + source: uri(format!( + "at://{author}/sh.tangled.graph.vouch/{record_subject}" + )), + sort_micros: micros, + } + } + + #[test] + fn viewer_authored_direct_vouch_is_returned() { + let store = EdgeStore::new(RuntimeHasher::from_seeds(1, 2, 3, 4)); + let viewer = did("did:plc:viewer"); + let actor = did("did:plc:actor"); + store.add(vouch_edge( + "sh.tangled.graph.vouch", + &actor, + &viewer, + &actor, + 42, + )); + let network = VouchNetworkFilter::new(vouch_network(&store, &viewer)); + let (items, _) = network_vouch_page( + &store, + &actor, + &network, + PageCursor::Start, + PageLimit::new(10).unwrap(), + ); + + assert_eq!(items.len(), 1); + assert_eq!(source_authority_did(&items[0].uri), Some(viewer)); + } + + #[test] + fn sent_vouches_match_their_record_subject_against_the_network() { + let store = EdgeStore::new(RuntimeHasher::from_seeds(1, 2, 3, 4)); + let actor = did("did:plc:actor"); + let member = did("did:plc:member"); + let outsider = did("did:plc:outsider"); + store.add(vouch_edge( + "sh.tangled.graph.vouch.by", + &actor, + &actor, + &member, + 42, + )); + store.add(vouch_edge( + "sh.tangled.graph.vouch.by", + &actor, + &actor, + &outsider, + 41, + )); + let network = VouchNetworkFilter::new(HashSet::from([member.as_ref().to_owned()])); + let (items, _) = network_vouch_page( + &store, + &actor, + &network, + PageCursor::Start, + PageLimit::new(10).unwrap(), + ); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].uri.rkey().unwrap().as_ref(), member.as_ref()); + } + + #[test] + fn batch_tags_each_actor_and_applies_the_limit_per_actor() { + let store = EdgeStore::new(RuntimeHasher::from_seeds(1, 2, 3, 4)); + let first = did("did:plc:first"); + let second = did("did:plc:second"); + let authors = [ + did("did:plc:author1"), + did("did:plc:author2"), + did("did:plc:author3"), + did("did:plc:author4"), + ]; + for (index, actor) in [first.clone(), second.clone()].iter().enumerate() { + for offset in 0..2 { + let author = &authors[index * 2 + offset]; + store.add(vouch_edge( + "sh.tangled.graph.vouch", + actor, + author, + actor, + 10 - offset as u64, + )); + } + } + let query = parse_network_vouch_query(Some( + "actors=did%3Aplc%3Afirst&actors=did%3Aplc%3Asecond&limit=1", + )) + .unwrap(); + let network = VouchNetworkFilter::new( + authors + .iter() + .map(|author| author.as_ref().to_owned()) + .collect(), + ); + let (items, cursor) = + network_vouch_pages(&store, &query.actors, &network, query.start, query.limit); + + assert_eq!( + items + .iter() + .map(|(actor, _)| actor.clone()) + .collect::>(), + vec![first, second] + ); + assert!(cursor.is_none()); + } + + #[test] + fn repeated_actors_collapse_into_one_group() { + let query = parse_network_vouch_query(Some( + "actors=did%3Aplc%3Afirst&actors=did%3Aplc%3Afirst&actors=did%3Aplc%3Asecond", + )) + .unwrap(); + + assert_eq!( + query.actors, + vec![did("did:plc:first"), did("did:plc:second")] + ); + } + + #[test] + fn cursor_is_rejected_for_multiple_actors() { + assert!(matches!( + parse_network_vouch_query(Some( + "actors=did%3Aplc%3Afirst&actors=did%3Aplc%3Asecond&cursor=42" + )), + Err(XrpcError::InvalidParams(_)) + )); + } } async fn list_network_vouches( State(state): State, ExtractOptionalServiceAuth(auth): ExtractOptionalServiceAuth, - XrpcQuery(q): XrpcQuery, + RawQuery(query): RawQuery, ) -> Result { - let actor = SubjectRef::Did(q.actor.clone()); - let limit = parse_limit(q.limit.and_then(|n| u32::try_from(n).ok()))?; - let start = parse_vouch_cursor(q.cursor.as_deref())?; + let q = parse_network_vouch_query(query.as_deref())?; let permit = state.heavy_permit()?; let vouch_nsid = nsid_static("sh.tangled.graph.vouch"); - - let viewer = auth - .as_ref() - .map(|auth| SubjectRef::Did(auth.did().into_static())); - let network = VouchNetworkFilter::new( - viewer - .as_ref() - .map(|viewer| vouch_network(&state, viewer)) - .unwrap_or_default(), - ); - let (page_items, next_cursor) = if network.is_empty() { - (Vec::new(), None) - } else { - let received = vouch_items( - &state.edges, - EdgeKey::new(vouch_nsid.clone(), actor.clone()), - VouchList::Received, - start, - ) - .filter(|item| network.matches(item)); - let sent = network - .contains(q.actor.as_ref()) - .then(|| { - vouch_items( - &state.edges, - EdgeKey::new(nsid_static("sh.tangled.graph.vouch.by"), actor), - VouchList::Sent, - start, - ) - }) - .into_iter() - .flatten(); - paginate_vouches(merge_vouches(received, sent), limit) + let (page_items, next_cursor) = match auth.as_ref() { + Some(auth) => { + let viewer = auth.did().into_static(); + let network = VouchNetworkFilter::new(vouch_network(&state.edges, &viewer)); + network_vouch_pages(&state.edges, &q.actors, &network, q.start, q.limit) + } + None => (Vec::new(), None), }; - - let views = hydrate_record_stream::>( + let views = hydrate_keyed_record_stream::<_, Vouch>( &state, vouch_nsid, page_items, HitProvenance::Indexed, - ); - Ok(json_stream::>, _>( + ) + .map_ok(|(actor, view)| NetworkVouchItem { actor, view }); + Ok(json_stream::>, _>( "items", views, paged_tail(next_cursor, None), diff --git a/lexicons/graph/listNetworkVouches.json b/lexicons/graph/listNetworkVouches.json index 92f8473de..f9ccc07ca 100644 --- a/lexicons/graph/listNetworkVouches.json +++ b/lexicons/graph/listNetworkVouches.json @@ -4,25 +4,31 @@ "defs": { "main": { "type": "query", - "description": "List vouches involving an actor as seen through a viewer's vouch network. Requires authentication.", + "description": "List vouches involving one or more actors as seen through a viewer's vouch network: vouches the viewer's network placed on an actor, and vouches an actor placed on the viewer's network. Requires authentication.", "parameters": { "type": "params", - "required": ["actor"], + "required": ["actors"], "properties": { - "actor": { - "type": "string", - "format": "did", - "description": "The DID of the profile whose network vouches to list." + "actors": { + "type": "array", + "items": { + "type": "string", + "format": "did" + }, + "minLength": 1, + "maxLength": 50, + "description": "DIDs of the profiles whose network vouches to list. At most 50 per request." }, "cursor": { "type": "string", - "description": "Pagination cursor" + "description": "Pagination cursor. Only accepted when a single actor is requested." }, "limit": { "type": "integer", "minimum": 1, "maximum": 100, - "default": 50 + "default": 50, + "description": "Maximum number of vouches per actor." } } }, @@ -43,8 +49,13 @@ }, "listItem": { "type": "object", - "required": ["uri", "value"], + "required": ["actor", "uri", "value"], "properties": { + "actor": { + "type": "string", + "format": "did", + "description": "The requested actor this vouch involves." + }, "uri": { "type": "string", "format": "at-uri" }, "cid": { "type": "string", "format": "cid" }, "value": { diff --git a/web/src/lib/api/graph.test.ts b/web/src/lib/api/graph.test.ts new file mode 100644 index 000000000..aa0b214f8 --- /dev/null +++ b/web/src/lib/api/graph.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { createBobbinClient } from "$lib/api/client"; +import { getViewerVouch, listNetworkVouches } from "$lib/api/graph"; + +const response = (items: unknown[] = []) => + new Response(JSON.stringify({ items }), { + status: 200, + headers: { "content-type": "application/json" } + }); + +describe("network vouches", () => { + it("deduplicates actors and chunks requests at fifty", async () => { + const fetch = vi.fn(async () => response()); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch }); + const actors = Array.from({ length: 55 }, (_, index) => `did:plc:actor${index}`); + await listNetworkVouches(ctx, [...actors, actors[0]]); + expect(fetch).toHaveBeenCalledTimes(2); + expect(new URL(String(fetch.mock.calls[0][0])).searchParams.getAll("actors")).toEqual( + actors.slice(0, 50) + ); + expect(new URL(String(fetch.mock.calls[1][0])).searchParams.getAll("actors")).toEqual( + actors.slice(50) + ); + }); + + it("groups tagged items by requested actor", async () => { + const alice = "did:plc:alice"; + const bob = "did:plc:bob"; + const item = (actor: string) => ({ + actor, + uri: `at://did:plc:viewer/sh.tangled.graph.vouch/${actor}`, + value: { + $type: "sh.tangled.graph.vouch", + kind: "vouch", + createdAt: "2026-01-01T00:00:00.000Z" + } + }); + const ctx = createBobbinClient({ + serviceUrl: "https://bobbin.test", + fetch: async () => response([item(bob), item(alice), item(bob)]) + }); + const result = await listNetworkVouches(ctx, [alice, bob]); + expect(result.items[alice]).toHaveLength(1); + expect(result.items[bob]).toHaveLength(2); + }); + + it("looks up a viewer direct vouch on the subject actor", async () => { + const viewer = "did:plc:viewer"; + const subject = "did:plc:subject"; + const fetch = vi.fn(async () => + response([ + { + actor: subject, + uri: `at://${viewer}/sh.tangled.graph.vouch/${subject}`, + value: { + $type: "sh.tangled.graph.vouch", + kind: "denounce", + reason: "reason", + createdAt: "2026-01-01T00:00:00.000Z" + } + } + ]) + ); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch }); + await expect(getViewerVouch(ctx, viewer, subject)).resolves.toEqual({ + kind: "denounce", + reason: "reason" + }); + const url = new URL(String(fetch.mock.calls[0][0])); + expect(url.searchParams.getAll("actors")).toEqual([subject]); + }); +}); diff --git a/web/src/lib/api/graph.ts b/web/src/lib/api/graph.ts index 0352f371f..b51c0b75f 100644 --- a/web/src/lib/api/graph.ts +++ b/web/src/lib/api/graph.ts @@ -214,20 +214,56 @@ export const deleteVouch = async ( await deleteRecord(agent, VOUCH_COLLECTION, subject); return { uri }; }; +export type NetworkVouchItem = Omit & { + value: VouchRecord; +}; + +export interface NetworkVouchesByActor { + items: Record; + cursor?: string; +} + +export const listNetworkVouches = async ( + ctx: BobbinContext, + actors: readonly string[], + options: { limit?: number; cursor?: string } = {} +): Promise => { + const unique = [...new Set(actors)]; + if (options.cursor && unique.length !== 1) { + throw new Error("A vouch cursor requires exactly one actor"); + } + const groups = Object.fromEntries(unique.map((actor) => [actor, [] as NetworkVouchItem[]])); + const pages = await Promise.all( + Array.from({ length: Math.ceil(unique.length / 50) }, (_, index) => + jsonGet( + ctx, + "sh.tangled.graph.listNetworkVouches", + { + actors: unique.slice(index * 50, (index + 1) * 50) as Did[], + limit: options.limit, + cursor: options.cursor + } satisfies ShTangledGraphListNetworkVouches.$params + ) + ) + ); + for (const page of pages) { + for (const item of page.items) groups[item.actor]?.push(item as NetworkVouchItem); + } + return { items: groups, cursor: pages[0]?.cursor }; +}; + export const getViewerVouch = async ( ctx: BobbinContext, viewer: string, subject: string ): Promise => { - const { items } = await jsonGet( - ctx, - "sh.tangled.graph.listNetworkVouches", - { actor: viewer as Did, limit: 100 } satisfies ShTangledGraphListNetworkVouches.$params - ); - const match = items.find( + const { items } = await listNetworkVouches(ctx, [subject], { limit: 100 }); + const match = items[subject]?.find( (item) => didFromUri(item.uri) === viewer && rkeyFromUri(item.uri) === subject ); if (!match) return null; - const value = match.value as VouchRecord; - return { kind: value.kind === "denounce" ? "denounce" : "vouch", reason: value.reason }; + return { + kind: match.value.kind === "denounce" ? "denounce" : "vouch", + reason: match.value.reason + }; }; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/graph/listNetworkVouches.ts b/web/src/lib/api/lexicons/types/sh/tangled/graph/listNetworkVouches.ts index 18530e950..74d62e7cb 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/graph/listNetworkVouches.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/graph/listNetworkVouches.ts @@ -6,6 +6,10 @@ const _listItemSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal("sh.tangled.graph.listNetworkVouches#listItem"), ), + /** + * The requested actor this vouch involves. + */ + actor: /*#__PURE__*/ v.didString(), cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), uri: /*#__PURE__*/ v.resourceUriString(), /** @@ -18,14 +22,20 @@ const _mainSchema = /*#__PURE__*/ v.query( { params: /*#__PURE__*/ v.object({ /** - * The DID of the profile whose network vouches to list. + * DIDs of the profiles whose network vouches to list. At most 50 per request. + * @minLength 1 + * @maxLength 50 */ - actor: /*#__PURE__*/ v.didString(), + actors: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.didString()), + [/*#__PURE__*/ v.arrayLength(1, 50)], + ), /** - * Pagination cursor + * Pagination cursor. Only accepted when a single actor is requested. */ cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), /** + * Maximum number of vouches per actor. * @minimum 1 * @maximum 100 * @default 50 diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts index 394fb41af..eb316ea3b 100644 --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -34,7 +34,7 @@ import { type RepoRecord, type ProfileRecord } from "$lib/api/records"; -import { getViewerVouch } from "$lib/api/graph"; +import { getViewerVouch, listNetworkVouches } from "$lib/api/graph"; import type { SearchPage } from "$lib/api/search"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; import type { VouchRecord } from "$lib/api/graph"; @@ -358,18 +358,13 @@ export interface VouchesPageOptions { limit?: number; } -// listNetworkVouches returns incoming and outgoing vouches interleaved in one -// list; pagination is disabled for now, so fetch a single page export const fetchVouchesPage = async ( ctx: BobbinContext, { did, cache, limit = PROFILE_PAGE_LIMIT }: VouchesPageOptions ): Promise => { const identity = cache ?? new IdentityCache(ctx); - const page = await fetchPage(ctx, "sh.tangled.graph.listNetworkVouches", { - actor: did as Did, - limit - }); - const items = await resolveVouches(page.items, did, identity); + const page = await listNetworkVouches(ctx, [did], { limit }); + const items = await resolveVouches(page.items[did] ?? [], did, identity); return { items }; }; -- 2.51.2