diff --git a/constellation/src/lib.rs b/constellation/src/lib.rs index bcb4e9b..93777dd 100644 --- a/constellation/src/lib.rs +++ b/constellation/src/lib.rs @@ -48,6 +48,20 @@ impl RecordId { pub fn rkey(&self) -> String { self.rkey.clone() } + pub fn uri(&self) -> String { + let RecordId { + did: Did(did), + collection, + rkey, + } = self; + format!("at://{did}/{collection}/{rkey}") + } +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct ManyToManyItem { + link_record: RecordId, + other_subject: String, } /// maybe the worst type in this repo, and there are some bad types diff --git a/constellation/src/server/mod.rs b/constellation/src/server/mod.rs index 18d1c78..687741a 100644 --- a/constellation/src/server/mod.rs +++ b/constellation/src/server/mod.rs @@ -18,7 +18,7 @@ use tokio::task::spawn_blocking; use tokio_util::sync::CancellationToken; use crate::storage::{LinkReader, Order, StorageStats}; -use crate::{CountsByCount, Did, RecordId}; +use crate::{CountsByCount, Did, ManyToManyItem, RecordId}; mod acceptable; mod filters; @@ -714,11 +714,6 @@ struct GetManyToManyItemsQuery { #[serde(default = "get_default_cursor_limit")] limit: u64, } -#[derive(Debug, Serialize)] -struct ManyToManyItem { - link: RecordId, - subject: String, -} #[derive(Template, Serialize)] #[template(path = "get-many-to-many.html.j2")] struct GetManyToManyItemsResponse { @@ -782,19 +777,10 @@ fn get_many_to_many( let cursor = paged.next.map(|next| ApiKeyedCursor { next }.into()); - let items: Vec = paged - .items - .into_iter() - .map(|(record_id, subject)| ManyToManyItem { - link: record_id, - subject, - }) - .collect(); - Ok(acceptable( accept, GetManyToManyItemsResponse { - items, + items: paged.items, cursor, query: (*query).clone(), }, diff --git a/constellation/src/storage/mem_store.rs b/constellation/src/storage/mem_store.rs index 842ff6a..33659cf 100644 --- a/constellation/src/storage/mem_store.rs +++ b/constellation/src/storage/mem_store.rs @@ -2,7 +2,7 @@ use super::{ LinkReader, LinkStorage, ManyToManyCursor, Order, PagedAppendingCollection, PagedOrderedCollection, StorageStats, }; -use crate::{ActionableEvent, CountsByCount, Did, RecordId}; +use crate::{ActionableEvent, CountsByCount, Did, ManyToManyItem, RecordId}; use anyhow::{anyhow, Result}; use links::CollectedLink; @@ -248,7 +248,7 @@ impl LinkReader for MemStorage { after: Option, filter_dids: &HashSet, filter_targets: &HashSet, - ) -> Result> { + ) -> Result> { // setup variables that we need later let path_to_other = RecordPath(path_to_other.to_string()); let filter_targets: HashSet = @@ -280,7 +280,7 @@ impl LinkReader for MemStorage { return Ok(PagedOrderedCollection::empty()); }; - let mut items: Vec<(usize, usize, RecordId, String)> = Vec::new(); + let mut items: Vec<(usize, usize, ManyToManyItem)> = Vec::new(); // iterate backwards (who linked to the target?) for (linker_idx, (did, rkey)) in linkers @@ -313,16 +313,15 @@ impl LinkReader for MemStorage { }) .take(limit as usize + 1 - items.len()) { - items.push(( - linker_idx, - link_idx, - RecordId { + let item = ManyToManyItem { + link_record: RecordId { did: did.clone(), collection: collection.to_string(), rkey: rkey.0.clone(), }, - fwd_target.0.clone(), - )); + other_subject: fwd_target.0.clone(), + }; + items.push((linker_idx, link_idx, item)); } // page full - eject @@ -332,14 +331,14 @@ impl LinkReader for MemStorage { } let next = (items.len() > limit as usize).then(|| { - let (l, f, _, _) = items[limit as usize - 1]; + let (l, f, _) = items[limit as usize - 1]; format!("{l},{f}") }); let items = items .into_iter() .take(limit as usize) - .map(|(_, _, rid, t)| (rid, t)) + .map(|(_, _, item)| item) .collect(); Ok(PagedOrderedCollection { items, next }) diff --git a/constellation/src/storage/mod.rs b/constellation/src/storage/mod.rs index 51f0c6f..d00d931 100644 --- a/constellation/src/storage/mod.rs +++ b/constellation/src/storage/mod.rs @@ -1,4 +1,4 @@ -use crate::{ActionableEvent, CountsByCount, Did, RecordId}; +use crate::{ActionableEvent, CountsByCount, Did, ManyToManyItem, RecordId}; use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -152,7 +152,7 @@ pub trait LinkReader: Clone + Send + Sync + 'static { after: Option, filter_dids: &HashSet, filter_to_targets: &HashSet, - ) -> Result>; + ) -> Result>; fn get_all_counts( &self, @@ -1817,13 +1817,17 @@ mod tests { 2, "both forward links at path_to_other should be emitted" ); - let mut targets: Vec<_> = result.items.iter().map(|(_, t)| t.as_str()).collect(); + let mut targets: Vec<_> = result + .items + .iter() + .map(|item| item.other_subject.as_str()) + .collect(); targets.sort(); assert_eq!(targets, vec!["b.com", "c.com"]); assert!(result .items .iter() - .all(|(r, _)| r.did.0 == "did:plc:asdf" && r.rkey == "asdf")); + .all(|item| item.link_record.uri() == "at://did:plc:asdf/app.t.c/asdf")); assert_eq!(result.next, None); }); @@ -1926,28 +1930,28 @@ mod tests { let b_items: Vec<_> = result .items .iter() - .filter(|(_, subject)| subject == "b.com") + .filter(|item| item.other_subject == "b.com") .collect(); assert_eq!(b_items.len(), 2); - assert!(b_items - .iter() - .any(|(r, _)| r.did.0 == "did:plc:asdf" && r.rkey == "asdf")); - assert!(b_items - .iter() - .any(|(r, _)| r.did.0 == "did:plc:asdf" && r.rkey == "asdf2")); + assert!(b_items.iter().any( + |item| item.link_record.did.0 == "did:plc:asdf" && item.link_record.rkey == "asdf" + )); + assert!(b_items.iter().any( + |item| item.link_record.did.0 == "did:plc:asdf" && item.link_record.rkey == "asdf2" + )); // Check c.com items let c_items: Vec<_> = result .items .iter() - .filter(|(_, subject)| subject == "c.com") + .filter(|item| item.other_subject == "c.com") .collect(); assert_eq!(c_items.len(), 2); - assert!(c_items - .iter() - .any(|(r, _)| r.did.0 == "did:plc:fdsa" && r.rkey == "fdsa")); - assert!(c_items - .iter() - .any(|(r, _)| r.did.0 == "did:plc:fdsa" && r.rkey == "fdsa2")); + assert!(c_items.iter().any( + |item| item.link_record.did.0 == "did:plc:fdsa" && item.link_record.rkey == "fdsa" + )); + assert!(c_items.iter().any( + |item| item.link_record.did.0 == "did:plc:fdsa" && item.link_record.rkey == "fdsa2" + )); // Test with DID filter - should only get records from did:plc:fdsa let result = storage.get_many_to_many( @@ -1961,8 +1965,14 @@ mod tests { &HashSet::new(), )?; assert_eq!(result.items.len(), 2); - assert!(result.items.iter().all(|(_, subject)| subject == "c.com")); - assert!(result.items.iter().all(|(r, _)| r.did.0 == "did:plc:fdsa")); + assert!(result + .items + .iter() + .all(|item| item.other_subject == "c.com")); + assert!(result + .items + .iter() + .all(|item| item.link_record.did.0 == "did:plc:fdsa")); // Test with target filter - should only get records linking to b.com let result = storage.get_many_to_many( @@ -1976,8 +1986,14 @@ mod tests { &HashSet::from_iter(["b.com".to_string()]), )?; assert_eq!(result.items.len(), 2); - assert!(result.items.iter().all(|(_, subject)| subject == "b.com")); - assert!(result.items.iter().all(|(r, _)| r.did.0 == "did:plc:asdf")); + assert!(result + .items + .iter() + .all(|item| item.other_subject == "b.com")); + assert!(result + .items + .iter() + .all(|item| item.link_record.did.0 == "did:plc:asdf")); // Pagination edge cases: we have 4 flat items @@ -2051,8 +2067,17 @@ mod tests { assert_eq!(result2.next, None, "next should be None on final page"); // Verify we got all 4 unique items across both pages (no duplicates, no gaps) - let mut all_rkeys: Vec<_> = result.items.iter().map(|(r, _)| r.rkey.clone()).collect(); - all_rkeys.extend(result2.items.iter().map(|(r, _)| r.rkey.clone())); + let mut all_rkeys: Vec<_> = result + .items + .iter() + .map(|item| item.link_record.rkey.clone()) + .collect(); + all_rkeys.extend( + result2 + .items + .iter() + .map(|item| item.link_record.rkey.clone()), + ); all_rkeys.sort(); assert_eq!( all_rkeys, @@ -2130,7 +2155,7 @@ mod tests { .items .iter() .chain(page2.items.iter()) - .map(|(_, t)| t.clone()) + .map(|item| item.other_subject.clone()) .collect(); all_targets.sort(); assert_eq!( diff --git a/constellation/src/storage/rocks_store.rs b/constellation/src/storage/rocks_store.rs index a6ce4eb..f721825 100644 --- a/constellation/src/storage/rocks_store.rs +++ b/constellation/src/storage/rocks_store.rs @@ -2,7 +2,7 @@ use super::{ ActionableEvent, LinkReader, LinkStorage, ManyToManyCursor, Order, PagedAppendingCollection, PagedOrderedCollection, StorageStats, }; -use crate::{CountsByCount, Did, RecordId}; +use crate::{CountsByCount, Did, ManyToManyItem, RecordId}; use anyhow::{anyhow, bail, Result}; use bincode::Options as BincodeOptions; @@ -1134,7 +1134,7 @@ impl LinkReader for RocksStorage { after: Option, filter_link_dids: &HashSet, filter_to_targets: &HashSet, - ) -> Result> { + ) -> Result> { // helper to resolve dids let resolve_active_did = |did_id: &DidId| -> Result> { let Some(did) = self.did_id_table.get_val_from_id(&self.db, did_id.0)? else { @@ -1192,9 +1192,9 @@ impl LinkReader for RocksStorage { }; let linkers = self.get_target_linkers(&target_id)?; - let mut items: Vec<(usize, usize, RecordId, String)> = Vec::new(); + let mut items: Vec<(usize, usize, ManyToManyItem)> = Vec::new(); - // iterate backwards (who linked to the target?) + // iterate backlinks (who linked to the target?) for (linker_idx, (did_id, rkey)) in linkers.0.iter().enumerate().skip_while(|(linker_idx, _)| { cursor.is_some_and(|c| *linker_idx < c.backlink as usize) @@ -1215,7 +1215,7 @@ impl LinkReader for RocksStorage { continue; }; - // iterate forward (which of these links point to the __other__ target?) + // iterate fwd links (which of these links point to the __other__ target?) for (link_idx, RecordLinkTarget(_, fwd_target_id)) in links .0 .into_iter() @@ -1250,7 +1250,11 @@ impl LinkReader for RocksStorage { collection: collection.0.clone(), rkey: rkey.0.clone(), }; - items.push((linker_idx, link_idx, record_id, fwd_target_key.0 .0)); + let item = ManyToManyItem { + link_record: record_id, + other_subject: fwd_target_key.0 .0, + }; + items.push((linker_idx, link_idx, item)); } // page full - eject @@ -1269,14 +1273,14 @@ impl LinkReader for RocksStorage { // forward_link_idx are skipped. This correctly resumes mid-record when // a single backlinker has multiple forward links at path_to_other. let next = (items.len() > limit as usize).then(|| { - let (l, f, _, _) = items[limit as usize - 1]; + let (l, f, _) = items[limit as usize - 1]; format!("{l},{f}") }); let items = items .into_iter() .take(limit as usize) - .map(|(_, _, rid, t)| (rid, t)) + .map(|(_, _, item)| item) .collect(); Ok(PagedOrderedCollection { items, next }) diff --git a/constellation/templates/get-many-to-many.html.j2 b/constellation/templates/get-many-to-many.html.j2 index 2f090a2..6cc06f5 100644 --- a/constellation/templates/get-many-to-many.html.j2 +++ b/constellation/templates/get-many-to-many.html.j2 @@ -19,16 +19,22 @@

Many-to-many links, most recent first:

{% for item in items %} -
Subject:    {{ item.subject }}
-DID:        {{ item.link.did().0 }}
-Collection: {{ item.link.collection }}
-RKey:       {{ item.link.rkey }}
--> browse record
+
Linking record:
+    {%- if let Some(uri) = item.link_record.uri().as_str()|to_browseable %} browse link record{% endif %}
+  DID:         {{ item.link_record.did().0 }}
+  Collection:  {{ item.link_record.collection() }}
+  RKey:        {{ item.link_record.rkey() }}
+Other subject: {{ item.other_subject }}
+  {%- if let Some(uri) = item.other_subject.as_str()|to_browseable %}
+  -> browse subject
+  {%- endif %}
+
{% endfor %} {% if let Some(c) = cursor %} diff --git a/constellation/templates/hello.html.j2 b/constellation/templates/hello.html.j2 index b853fd0..f4dc69d 100644 --- a/constellation/templates/hello.html.j2 +++ b/constellation/templates/hello.html.j2 @@ -98,7 +98,14 @@

Try it:

- {% call try_it::get_many_to_many("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", "reply.parent.uri", [""], [""], 16) %} + {% call try_it::get_many_to_many( + "at://did:plc:uyauirpjzk6le4ygqzatcwnq/app.bsky.graph.list/3lzhg33t5bf2h", + "app.bsky.graph.listitem:list", + "subject", + [""], + [""], + 16, + ) %}

GET /xrpc/blue.microcosm.links.getDistinct