Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

Compare changes

Choose any two refs to compare.

Changed files
+813 -5
constellation
lexicons
blue.microcosm
+7 -1
constellation/src/lib.rs
··· 31 31 } 32 32 } 33 33 34 - #[derive(Debug, PartialEq, Serialize, Deserialize)] 34 + #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] 35 35 pub struct RecordId { 36 36 pub did: Did, 37 37 pub collection: String, ··· 48 48 pub fn rkey(&self) -> String { 49 49 self.rkey.clone() 50 50 } 51 + } 52 + 53 + #[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)] 54 + pub struct RecordsBySubject { 55 + pub subject: String, 56 + pub records: Vec<RecordId>, 51 57 } 52 58 53 59 /// maybe the worst type in this repo, and there are some bad types
+103 -1
constellation/src/server/mod.rs
··· 18 18 use tokio_util::sync::CancellationToken; 19 19 20 20 use crate::storage::{LinkReader, StorageStats}; 21 - use crate::{CountsByCount, Did, RecordId}; 21 + use crate::{CountsByCount, Did, RecordId, RecordsBySubject}; 22 22 23 23 mod acceptable; 24 24 mod filters; ··· 83 83 let store = store.clone(); 84 84 move |accept, query| async { 85 85 spawn_blocking(|| count_distinct_dids(accept, query, store)) 86 + .await 87 + .map_err(to500)? 88 + } 89 + }), 90 + ) 91 + .route( 92 + "/xrpc/blue.microcosm.links.getManyToMany", 93 + get({ 94 + let store = store.clone(); 95 + move |accept, query| async { 96 + spawn_blocking(|| get_many_to_many(accept, query, store)) 86 97 .await 87 98 .map_err(to500)? 88 99 } ··· 580 591 accept, 581 592 GetLinkItemsResponse { 582 593 total: paged.total, 594 + linking_records: paged.items, 595 + cursor, 596 + query: (*query).clone(), 597 + }, 598 + )) 599 + } 600 + 601 + #[derive(Clone, Deserialize)] 602 + #[serde(rename_all = "camelCase")] 603 + struct GetManyToManyItemsQuery { 604 + subject: String, 605 + source: String, 606 + /// path to the secondary link in the linking record 607 + path_to_other: String, 608 + /// filter to linking records (join of the m2m) by these DIDs 609 + #[serde(default)] 610 + did: Vec<String>, 611 + /// filter to specific secondary records 612 + #[serde(default)] 613 + other_subject: Vec<String>, 614 + cursor: Option<OpaqueApiCursor>, 615 + #[serde(default = "get_default_cursor_limit")] 616 + limit: u64, 617 + } 618 + #[derive(Template, Serialize)] 619 + #[template(path = "get-many-to-many.html.j2")] 620 + struct GetManyToManyItemsResponse { 621 + linking_records: Vec<RecordsBySubject>, 622 + cursor: Option<OpaqueApiCursor>, 623 + #[serde(skip_serializing)] 624 + query: GetManyToManyItemsQuery, 625 + } 626 + fn get_many_to_many( 627 + accept: ExtractAccept, 628 + query: axum_extra::extract::Query<GetManyToManyItemsQuery>, // supports multiple param occurrences 629 + store: impl LinkReader, 630 + ) -> Result<impl IntoResponse, http::StatusCode> { 631 + let after = query 632 + .cursor 633 + .clone() 634 + .map(|oc| ApiKeyedCursor::try_from(oc).map_err(|_| http::StatusCode::BAD_REQUEST)) 635 + .transpose()? 636 + .map(|c| c.next); 637 + 638 + let limit = query.limit; 639 + if limit > DEFAULT_CURSOR_LIMIT_MAX { 640 + return Err(http::StatusCode::BAD_REQUEST); 641 + } 642 + 643 + let filter_dids: HashSet<Did> = HashSet::from_iter( 644 + query 645 + .did 646 + .iter() 647 + .map(|d| d.trim()) 648 + .filter(|d| !d.is_empty()) 649 + .map(|d| Did(d.to_string())), 650 + ); 651 + 652 + let filter_other_subjects: HashSet<String> = HashSet::from_iter( 653 + query 654 + .other_subject 655 + .iter() 656 + .map(|s| s.trim().to_string()) 657 + .filter(|s| !s.is_empty()), 658 + ); 659 + 660 + let Some((collection, path)) = query.source.split_once(':') else { 661 + return Err(http::StatusCode::BAD_REQUEST); 662 + }; 663 + let path = format!(".{path}"); 664 + 665 + let path_to_other = format!(".{}", query.path_to_other); 666 + 667 + let paged = store 668 + .get_many_to_many( 669 + &query.subject, 670 + collection, 671 + &path, 672 + &path_to_other, 673 + limit, 674 + after, 675 + &filter_dids, 676 + &filter_other_subjects, 677 + ) 678 + .map_err(|_| http::StatusCode::INTERNAL_SERVER_ERROR)?; 679 + 680 + let cursor = paged.next.map(|next| ApiKeyedCursor { next }.into()); 681 + 682 + Ok(acceptable( 683 + accept, 684 + GetManyToManyItemsResponse { 583 685 linking_records: paged.items, 584 686 cursor, 585 687 query: (*query).clone(),
+97 -1
constellation/src/storage/mem_store.rs
··· 1 1 use super::{ 2 2 LinkReader, LinkStorage, PagedAppendingCollection, PagedOrderedCollection, StorageStats, 3 3 }; 4 - use crate::{ActionableEvent, CountsByCount, Did, RecordId}; 4 + use crate::{ActionableEvent, CountsByCount, Did, RecordId, RecordsBySubject}; 5 5 use anyhow::Result; 6 6 use links::CollectedLink; 7 7 use std::collections::{HashMap, HashSet}; ··· 232 232 .map(|(did, _)| did) 233 233 .collect::<HashSet<_>>() 234 234 .len() as u64) 235 + } 236 + 237 + fn get_many_to_many( 238 + &self, 239 + target: &str, 240 + collection: &str, 241 + path: &str, 242 + path_to_other: &str, 243 + limit: u64, 244 + after: Option<String>, 245 + filter_dids: &HashSet<Did>, 246 + filter_to_targets: &HashSet<String>, 247 + ) -> Result<PagedOrderedCollection<RecordsBySubject, String>> { 248 + let empty_res = Ok(PagedOrderedCollection { 249 + items: Vec::new(), 250 + next: None, 251 + }); 252 + 253 + // struct MemStorageData { 254 + // dids: HashMap<Did, bool>, 255 + // targets: HashMap<Target, HashMap<Source, Linkers>>, 256 + // links: HashMap<Did, HashMap<RepoId, Vec<(RecordPath, Target)>>>, 257 + // } 258 + let data = self.0.lock().unwrap(); 259 + 260 + let Some(sources) = data.targets.get(&Target::new(target)) else { 261 + return empty_res; 262 + }; 263 + let Some(linkers) = sources.get(&Source::new(collection, path)) else { 264 + return empty_res; 265 + }; 266 + let path_to_other = RecordPath::new(path_to_other); 267 + 268 + // Convert filter_to_targets to Target objects for comparison 269 + let filter_to_target_objs: HashSet<Target> = 270 + HashSet::from_iter(filter_to_targets.iter().map(|s| Target::new(s))); 271 + 272 + let mut grouped_links: HashMap<Target, Vec<RecordId>> = HashMap::new(); 273 + for (did, rkey) in linkers.iter().flatten().cloned() { 274 + // Filter by DID if filter is provided 275 + if !filter_dids.is_empty() && !filter_dids.contains(&did) { 276 + continue; 277 + } 278 + if let Some(fwd_target) = data 279 + .links 280 + .get(&did) 281 + .unwrap_or(&HashMap::new()) 282 + .get(&RepoId { 283 + collection: collection.to_string(), 284 + rkey: rkey.clone(), 285 + }) 286 + .unwrap_or(&Vec::new()) 287 + .iter() 288 + .find_map(|(path, target)| { 289 + if *path == path_to_other 290 + && (filter_to_target_objs.is_empty() 291 + || filter_to_target_objs.contains(target)) 292 + { 293 + Some(target) 294 + } else { 295 + None 296 + } 297 + }) 298 + { 299 + let record_ids = grouped_links.entry(fwd_target.clone()).or_default(); 300 + record_ids.push(RecordId { 301 + did, 302 + collection: collection.to_string(), 303 + rkey: rkey.0, 304 + }); 305 + } 306 + } 307 + 308 + let mut items = grouped_links 309 + .into_iter() 310 + .map(|(t, r)| RecordsBySubject { 311 + subject: t.0, 312 + records: r, 313 + }) 314 + .collect::<Vec<_>>(); 315 + 316 + items.sort_by(|a, b| a.subject.cmp(&b.subject)); 317 + 318 + items = items 319 + .into_iter() 320 + .skip_while(|item| after.as_ref().map(|a| &item.subject <= a).unwrap_or(false)) 321 + .take(limit as usize) 322 + .collect(); 323 + 324 + let next = if items.len() as u64 >= limit { 325 + items.last().map(|item| item.subject.clone()) 326 + } else { 327 + None 328 + }; 329 + 330 + Ok(PagedOrderedCollection { items, next }) 235 331 } 236 332 237 333 fn get_links(
+241 -1
constellation/src/storage/mod.rs
··· 1 - use crate::{ActionableEvent, CountsByCount, Did, RecordId}; 1 + use crate::{ActionableEvent, CountsByCount, Did, RecordId, RecordsBySubject}; 2 2 use anyhow::Result; 3 3 use serde::{Deserialize, Serialize}; 4 4 use std::collections::{HashMap, HashSet}; ··· 103 103 104 104 fn get_all_record_counts(&self, _target: &str) 105 105 -> Result<HashMap<String, HashMap<String, u64>>>; 106 + 107 + fn get_many_to_many( 108 + &self, 109 + target: &str, 110 + collection: &str, 111 + path: &str, 112 + path_to_other: &str, 113 + limit: u64, 114 + after: Option<String>, 115 + filter_dids: &HashSet<Did>, 116 + filter_to_targets: &HashSet<String>, 117 + ) -> Result<PagedOrderedCollection<RecordsBySubject, String>>; 106 118 107 119 fn get_all_counts( 108 120 &self, ··· 1551 1563 next: None, 1552 1564 } 1553 1565 ); 1566 + }); 1567 + 1568 + test_each_storage!(get_m2m_empty, |storage| { 1569 + assert_eq!( 1570 + storage.get_many_to_many( 1571 + "a.com", 1572 + "a.b.c", 1573 + ".d.e", 1574 + ".f.g", 1575 + 10, 1576 + None, 1577 + &HashSet::new(), 1578 + &HashSet::new(), 1579 + )?, 1580 + PagedOrderedCollection { 1581 + items: vec![], 1582 + next: None, 1583 + } 1584 + ); 1585 + }); 1586 + 1587 + test_each_storage!(get_m2m_single, |storage| { 1588 + storage.push( 1589 + &ActionableEvent::CreateLinks { 1590 + record_id: RecordId { 1591 + did: "did:plc:asdf".into(), 1592 + collection: "app.t.c".into(), 1593 + rkey: "asdf".into(), 1594 + }, 1595 + links: vec![ 1596 + CollectedLink { 1597 + target: Link::Uri("a.com".into()), 1598 + path: ".abc.uri".into(), 1599 + }, 1600 + CollectedLink { 1601 + target: Link::Uri("b.com".into()), 1602 + path: ".def.uri".into(), 1603 + }, 1604 + CollectedLink { 1605 + target: Link::Uri("b.com".into()), 1606 + path: ".ghi.uri".into(), 1607 + }, 1608 + ], 1609 + }, 1610 + 0, 1611 + )?; 1612 + assert_eq!( 1613 + storage.get_many_to_many( 1614 + "a.com", 1615 + "app.t.c", 1616 + ".abc.uri", 1617 + ".def.uri", 1618 + 10, 1619 + None, 1620 + &HashSet::new(), 1621 + &HashSet::new(), 1622 + )?, 1623 + PagedOrderedCollection { 1624 + items: vec![RecordsBySubject { 1625 + subject: "b.com".to_string(), 1626 + records: vec![RecordId { 1627 + did: "did:plc:asdf".into(), 1628 + collection: "app.t.c".into(), 1629 + rkey: "asdf".into(), 1630 + }] 1631 + }], 1632 + next: None, 1633 + } 1634 + ); 1635 + }); 1636 + 1637 + test_each_storage!(get_m2m_filters, |storage| { 1638 + storage.push( 1639 + &ActionableEvent::CreateLinks { 1640 + record_id: RecordId { 1641 + did: "did:plc:asdf".into(), 1642 + collection: "app.t.c".into(), 1643 + rkey: "asdf".into(), 1644 + }, 1645 + links: vec![ 1646 + CollectedLink { 1647 + target: Link::Uri("a.com".into()), 1648 + path: ".abc.uri".into(), 1649 + }, 1650 + CollectedLink { 1651 + target: Link::Uri("b.com".into()), 1652 + path: ".def.uri".into(), 1653 + }, 1654 + ], 1655 + }, 1656 + 0, 1657 + )?; 1658 + storage.push( 1659 + &ActionableEvent::CreateLinks { 1660 + record_id: RecordId { 1661 + did: "did:plc:asdf".into(), 1662 + collection: "app.t.c".into(), 1663 + rkey: "asdf2".into(), 1664 + }, 1665 + links: vec![ 1666 + CollectedLink { 1667 + target: Link::Uri("a.com".into()), 1668 + path: ".abc.uri".into(), 1669 + }, 1670 + CollectedLink { 1671 + target: Link::Uri("b.com".into()), 1672 + path: ".def.uri".into(), 1673 + }, 1674 + ], 1675 + }, 1676 + 1, 1677 + )?; 1678 + storage.push( 1679 + &ActionableEvent::CreateLinks { 1680 + record_id: RecordId { 1681 + did: "did:plc:fdsa".into(), 1682 + collection: "app.t.c".into(), 1683 + rkey: "fdsa".into(), 1684 + }, 1685 + links: vec![ 1686 + CollectedLink { 1687 + target: Link::Uri("a.com".into()), 1688 + path: ".abc.uri".into(), 1689 + }, 1690 + CollectedLink { 1691 + target: Link::Uri("c.com".into()), 1692 + path: ".def.uri".into(), 1693 + }, 1694 + ], 1695 + }, 1696 + 2, 1697 + )?; 1698 + storage.push( 1699 + &ActionableEvent::CreateLinks { 1700 + record_id: RecordId { 1701 + did: "did:plc:fdsa".into(), 1702 + collection: "app.t.c".into(), 1703 + rkey: "fdsa2".into(), 1704 + }, 1705 + links: vec![ 1706 + CollectedLink { 1707 + target: Link::Uri("a.com".into()), 1708 + path: ".abc.uri".into(), 1709 + }, 1710 + CollectedLink { 1711 + target: Link::Uri("c.com".into()), 1712 + path: ".def.uri".into(), 1713 + }, 1714 + ], 1715 + }, 1716 + 3, 1717 + )?; 1718 + 1719 + // Test without filters - should get all records grouped by secondary target 1720 + let result = storage.get_many_to_many( 1721 + "a.com", 1722 + "app.t.c", 1723 + ".abc.uri", 1724 + ".def.uri", 1725 + 10, 1726 + None, 1727 + &HashSet::new(), 1728 + &HashSet::new(), 1729 + )?; 1730 + assert_eq!(result.items.len(), 2); 1731 + assert_eq!(result.next, None); 1732 + // Find b.com group 1733 + let b_group = result 1734 + .items 1735 + .iter() 1736 + .find(|group| group.subject == "b.com") 1737 + .unwrap(); 1738 + assert_eq!(b_group.subject, "b.com"); 1739 + assert_eq!(b_group.records.len(), 2); 1740 + assert!(b_group.records 1741 + .iter() 1742 + .any(|r| r.did.0 == "did:plc:asdf" && r.rkey == "asdf")); 1743 + assert!(b_group.records 1744 + .iter() 1745 + .any(|r| r.did.0 == "did:plc:asdf" && r.rkey == "asdf2")); 1746 + // Find c.com group 1747 + let c_group = result 1748 + .items 1749 + .iter() 1750 + .find(|group| group.subject == "c.com") 1751 + .unwrap(); 1752 + assert_eq!(c_group.subject, "c.com"); 1753 + assert_eq!(c_group.records.len(), 2); 1754 + assert!(c_group.records 1755 + .iter() 1756 + .any(|r| r.did.0 == "did:plc:fdsa" && r.rkey == "fdsa")); 1757 + assert!(c_group.records 1758 + .iter() 1759 + .any(|r| r.did.0 == "did:plc:fdsa" && r.rkey == "fdsa2")); 1760 + 1761 + // Test with DID filter - should only get records from did:plc:fdsa 1762 + let result = storage.get_many_to_many( 1763 + "a.com", 1764 + "app.t.c", 1765 + ".abc.uri", 1766 + ".def.uri", 1767 + 10, 1768 + None, 1769 + &HashSet::from_iter([Did("did:plc:fdsa".to_string())]), 1770 + &HashSet::new(), 1771 + )?; 1772 + assert_eq!(result.items.len(), 1); 1773 + let group = &result.items[0]; 1774 + assert_eq!(group.subject, "c.com"); 1775 + assert_eq!(group.records.len(), 2); 1776 + assert!(group.records.iter().all(|r| r.did.0 == "did:plc:fdsa")); 1777 + 1778 + // Test with target filter - should only get records linking to b.com 1779 + let result = storage.get_many_to_many( 1780 + "a.com", 1781 + "app.t.c", 1782 + ".abc.uri", 1783 + ".def.uri", 1784 + 10, 1785 + None, 1786 + &HashSet::new(), 1787 + &HashSet::from_iter(["b.com".to_string()]), 1788 + )?; 1789 + assert_eq!(result.items.len(), 1); 1790 + let group = &result.items[0]; 1791 + assert_eq!(group.subject, "b.com"); 1792 + assert_eq!(group.records.len(), 2); 1793 + assert!(group.records.iter().all(|r| r.did.0 == "did:plc:asdf")); 1554 1794 }); 1555 1795 }
+147 -1
constellation/src/storage/rocks_store.rs
··· 2 2 ActionableEvent, LinkReader, LinkStorage, PagedAppendingCollection, PagedOrderedCollection, 3 3 StorageStats, 4 4 }; 5 - use crate::{CountsByCount, Did, RecordId}; 5 + use crate::{CountsByCount, Did, RecordId, RecordsBySubject}; 6 6 use anyhow::{bail, Result}; 7 7 use bincode::Options as BincodeOptions; 8 8 use links::CollectedLink; ··· 1120 1120 } else { 1121 1121 Ok(0) 1122 1122 } 1123 + } 1124 + 1125 + fn get_many_to_many( 1126 + &self, 1127 + target: &str, 1128 + collection: &str, 1129 + path: &str, 1130 + path_to_other: &str, 1131 + limit: u64, 1132 + after: Option<String>, 1133 + filter_dids: &HashSet<Did>, 1134 + filter_to_targets: &HashSet<String>, 1135 + ) -> Result<PagedOrderedCollection<RecordsBySubject, String>> { 1136 + let collection = Collection(collection.to_string()); 1137 + let path = RPath(path.to_string()); 1138 + 1139 + let target_key = TargetKey(Target(target.to_string()), collection.clone(), path); 1140 + 1141 + let after = after.map(|s| s.parse::<u64>().map(TargetId)).transpose()?; 1142 + 1143 + let Some(target_id) = self.target_id_table.get_id_val(&self.db, &target_key)? else { 1144 + eprintln!("Target not found for {target_key:?}"); 1145 + return Ok(Default::default()); 1146 + }; 1147 + 1148 + let filter_did_ids: HashMap<DidId, bool> = filter_dids 1149 + .iter() 1150 + .filter_map(|did| self.did_id_table.get_id_val(&self.db, did).transpose()) 1151 + .collect::<Result<Vec<DidIdValue>>>()? 1152 + .into_iter() 1153 + .map(|DidIdValue(id, active)| (id, active)) 1154 + .collect(); 1155 + 1156 + let mut filter_to_target_ids: HashSet<TargetId> = HashSet::new(); 1157 + for t in filter_to_targets { 1158 + for (_, target_id) in self.iter_targets_for_target(&Target(t.to_string())) { 1159 + filter_to_target_ids.insert(target_id); 1160 + } 1161 + } 1162 + 1163 + let linkers = self.get_target_linkers(&target_id)?; 1164 + 1165 + // we want to provide many to many which effectively means that we want to show a specific 1166 + // list of reords that is linked to by a specific number of linkers 1167 + let mut grouped_links: BTreeMap<TargetId, Vec<RecordId>> = BTreeMap::new(); 1168 + for (did_id, rkey) in linkers.0 { 1169 + if did_id.is_empty() { 1170 + continue; 1171 + } 1172 + 1173 + if !filter_did_ids.is_empty() && filter_did_ids.get(&did_id) != Some(&true) { 1174 + continue; 1175 + } 1176 + 1177 + // Make sure the current did is active 1178 + let Some(did) = self.did_id_table.get_val_from_id(&self.db, did_id.0)? else { 1179 + eprintln!("failed to look up did from did_id {did_id:?}"); 1180 + continue; 1181 + }; 1182 + let Some(DidIdValue(_, active)) = self.did_id_table.get_id_val(&self.db, &did)? else { 1183 + eprintln!("failed to look up did_value from did_id {did_id:?}: {did:?}: data consistency bug?"); 1184 + continue; 1185 + }; 1186 + if !active { 1187 + continue; 1188 + } 1189 + 1190 + let record_link_key = RecordLinkKey(did_id, collection.clone(), rkey.clone()); 1191 + let Some(targets) = self.get_record_link_targets(&record_link_key)? else { 1192 + continue; 1193 + }; 1194 + 1195 + let Some(fwd_target) = targets 1196 + .0 1197 + .into_iter() 1198 + .filter_map(|RecordLinkTarget(rpath, target_id)| { 1199 + if rpath.0 == path_to_other 1200 + && (filter_to_target_ids.is_empty() 1201 + || filter_to_target_ids.contains(&target_id)) 1202 + { 1203 + Some(target_id) 1204 + } else { 1205 + None 1206 + } 1207 + }) 1208 + .take(1) 1209 + .next() 1210 + else { 1211 + eprintln!("no forward match found."); 1212 + continue; 1213 + }; 1214 + 1215 + // pagination logic mirrors what is currently done in get_many_to_many_counts 1216 + if after.as_ref().map(|a| fwd_target <= *a).unwrap_or(false) { 1217 + continue; 1218 + } 1219 + let page_is_full = grouped_links.len() as u64 >= limit; 1220 + if page_is_full { 1221 + let current_max = grouped_links.keys().next_back().unwrap(); 1222 + if fwd_target > *current_max { 1223 + continue; 1224 + } 1225 + } 1226 + 1227 + // pagination, continued 1228 + let mut should_evict = false; 1229 + let entry = grouped_links.entry(fwd_target.clone()).or_insert_with(|| { 1230 + should_evict = page_is_full; 1231 + Vec::default() 1232 + }); 1233 + entry.push(RecordId { 1234 + did, 1235 + collection: collection.0.clone(), 1236 + rkey: rkey.0, 1237 + }); 1238 + 1239 + if should_evict { 1240 + grouped_links.pop_last(); 1241 + } 1242 + } 1243 + 1244 + let mut items: Vec<RecordsBySubject> = Vec::with_capacity(grouped_links.len()); 1245 + for (fwd_target_id, records) in &grouped_links { 1246 + let Some(target_key) = self 1247 + .target_id_table 1248 + .get_val_from_id(&self.db, fwd_target_id.0)? 1249 + else { 1250 + eprintln!("failed to look up target from target_id {fwd_target_id:?}"); 1251 + continue; 1252 + }; 1253 + 1254 + let target_string = target_key.0 .0; 1255 + 1256 + items.push(RecordsBySubject { 1257 + subject: target_string, 1258 + records: records.clone(), 1259 + }); 1260 + } 1261 + 1262 + let next = if grouped_links.len() as u64 >= limit { 1263 + grouped_links.keys().next_back().map(|k| format!("{}", k.0)) 1264 + } else { 1265 + None 1266 + }; 1267 + 1268 + Ok(PagedOrderedCollection { items, next }) 1123 1269 } 1124 1270 1125 1271 fn get_links(
+60
constellation/templates/get-many-to-many.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% import "try-it-macros.html.j2" as try_it %} 3 + 4 + {% block title %}Many-to-Many Links{% endblock %} 5 + {% block description %}All {{ query.source }} records with many-to-many links to {{ query.subject }} joining through {{ query.path_to_other }}{% endblock %} 6 + 7 + {% block content %} 8 + 9 + {% call try_it::get_many_to_many(query.subject, query.source, query.path_to_other, query.did, query.other_subject, query.limit) %} 10 + 11 + <h2> 12 + Many-to-many links to <code>{{ query.subject }}</code> 13 + {% if let Some(browseable_uri) = query.subject|to_browseable %} 14 + <small style="font-weight: normal; font-size: 1rem"><a href="{{ browseable_uri }}">browse record</a></small> 15 + {% endif %} 16 + </h2> 17 + 18 + <p><strong>Many-to-many links</strong> from <code>{{ query.source }}</code> joining through <code>{{ query.path_to_other }}</code></p> 19 + 20 + <ul> 21 + <li>See all links to this target at <code>/links/all</code>: <a href="/links/all?target={{ query.subject|urlencode }}">/links/all?target={{ query.subject }}</a></li> 22 + </ul> 23 + 24 + <h3>Many-to-many links, most recent first:</h3> 25 + 26 + {% for group in linking_records %} 27 + <h4>Target: <code>{{ group.subject }}</code> <small>(<a href="/links/all?target={{ group.subject|urlencode }}">view all links</a>)</small></h4> 28 + {% for record in group.records %} 29 + <pre style="display: block; margin: 1em 2em" class="code"><strong>DID</strong>: {{ record.did().0 }} 30 + <strong>Collection</strong>: {{ record.collection }} 31 + <strong>RKey</strong>: {{ record.rkey }} 32 + -> <a href="https://pdsls.dev/at://{{ record.did().0 }}/{{ record.collection }}/{{ record.rkey }}">browse record</a></pre> 33 + {% endfor %} 34 + {% endfor %} 35 + 36 + {% if let Some(c) = cursor %} 37 + <form method="get" action="/xrpc/blue.microcosm.links.getManyToMany"> 38 + <input type="hidden" name="subject" value="{{ query.subject }}" /> 39 + <input type="hidden" name="source" value="{{ query.source }}" /> 40 + <input type="hidden" name="pathToOther" value="{{ query.path_to_other }}" /> 41 + {% for did in query.did %} 42 + <input type="hidden" name="did" value="{{ did }}" /> 43 + {% endfor %} 44 + {% for other in query.other_subject %} 45 + <input type="hidden" name="otherSubject" value="{{ other }}" /> 46 + {% endfor %} 47 + <input type="hidden" name="limit" value="{{ query.limit }}" /> 48 + <input type="hidden" name="cursor" value={{ c|json|safe }} /> 49 + <button type="submit">next page&hellip;</button> 50 + </form> 51 + {% else %} 52 + <button disabled><em>end of results</em></button> 53 + {% endif %} 54 + 55 + <details> 56 + <summary>Raw JSON response</summary> 57 + <pre class="code">{{ self|tojson }}</pre> 58 + </details> 59 + 60 + {% endblock %}
+19
constellation/templates/hello.html.j2
··· 81 81 ) %} 82 82 83 83 84 + <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToMany</code></h3> 85 + 86 + <p>A list of many-to-many join records linking to a target and a secondary target.</p> 87 + 88 + <h4>Query parameters:</h4> 89 + 90 + <ul> 91 + <li><p><code>subject</code>: required, must url-encode. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 92 + <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li> 93 + <li><p><code>pathToOther</code>: required. Path to the secondary link in the many-to-many record. Example: <code>otherThing.uri</code></p></li> 94 + <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 95 + <li><p><code>otherSubject</code>: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple subjects. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 96 + <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 97 + </ul> 98 + 99 + <p style="margin-bottom: 0"><strong>Try it:</strong></p> 100 + {% 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) %} 101 + 102 + 84 103 <h3 class="route"><code>GET /links</code></h3> 85 104 86 105 <p>A list of records linking to a target.</p>
+30
constellation/templates/try-it-macros.html.j2
··· 66 66 </script> 67 67 {% endmacro %} 68 68 69 + {% macro get_many_to_many(subject, source, pathToOther, dids, otherSubjects, limit) %} 70 + <form method="get" action="/xrpc/blue.microcosm.links.getManyToMany"> 71 + <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getManyToMany 72 + ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> 73 + &source= <input type="text" name="source" value="{{ source }}" placeholder="app.bsky.feed.like:subject" /> 74 + &pathToOther= <input type="text" name="pathToOther" value="{{ pathToOther }}" placeholder="otherThing" /> 75 + {%- for did in dids %}{% if !did.is_empty() %} 76 + &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 77 + <span id="m2m-did-placeholder"></span> <button id="m2m-add-did">+ did filter</button> 78 + {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %} 79 + &otherSubject= <input type="text" name="otherSubject" value="{{ otherSubject }}" placeholder="at-uri, did, uri..." />{% endif %}{% endfor %} 80 + <span id="m2m-other-placeholder"></span> <button id="m2m-add-other">+ other subject filter</button> 81 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get many-to-many links</button></pre> 82 + </form> 83 + <script> 84 + const m2mAddDidButton = document.getElementById('m2m-add-did'); 85 + const m2mDidPlaceholder = document.getElementById('m2m-did-placeholder'); 86 + m2mAddDidButton.addEventListener('click', e => { 87 + e.preventDefault(); 88 + const i = document.createElement('input'); 89 + i.placeholder = 'did:plc:...'; 90 + i.name = "did" 91 + const p = m2mAddDidButton.parentNode; 92 + p.insertBefore(document.createTextNode('&did= '), m2mDidPlaceholder); 93 + p.insertBefore(i, m2mDidPlaceholder); 94 + p.insertBefore(document.createTextNode('\n '), m2mDidPlaceholder); 95 + }); 96 + </script> 97 + {% endmacro %} 98 + 69 99 {% macro links(target, collection, path, dids, limit) %} 70 100 <form method="get" action="/links"> 71 101 <pre class="code"><strong>GET</strong> /links
+109
lexicons/blue.microcosm/links/getManyToMany.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.microcosm.links.getManyToMany", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get records that link to a primary subject, grouped by the secondary subjects they also reference", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["subject", "source", "pathToOther"], 11 + "properties": { 12 + "subject": { 13 + "type": "string", 14 + "format": "uri", 15 + "description": "the primary target being linked to (at-uri, did, or uri)" 16 + }, 17 + "source": { 18 + "type": "string", 19 + "description": "collection and path specification for the primary link (e.g., 'app.bsky.feed.like:subject.uri')" 20 + }, 21 + "pathToOther": { 22 + "type": "string", 23 + "description": "path to the secondary link in the many-to-many record (e.g., 'otherThing.uri')" 24 + }, 25 + "did": { 26 + "type": "array", 27 + "description": "filter links to those from specific users", 28 + "items": { 29 + "type": "string", 30 + "format": "did" 31 + } 32 + }, 33 + "otherSubject": { 34 + "type": "array", 35 + "description": "filter secondary links to specific subjects", 36 + "items": { 37 + "type": "string" 38 + } 39 + }, 40 + "limit": { 41 + "type": "integer", 42 + "minimum": 1, 43 + "maximum": 100, 44 + "default": 16, 45 + "description": "number of results to return" 46 + } 47 + } 48 + }, 49 + "output": { 50 + "encoding": "application/json", 51 + "schema": { 52 + "type": "object", 53 + "required": ["linking_records"], 54 + "properties": { 55 + "linking_records": { 56 + "type": "array", 57 + "items": { 58 + "type": "ref", 59 + "ref": "#recordsBySubject" 60 + } 61 + }, 62 + "cursor": { 63 + "type": "string", 64 + "description": "pagination cursor" 65 + } 66 + } 67 + } 68 + } 69 + }, 70 + "recordsBySubject": { 71 + "type": "object", 72 + "required": ["subject", "records"], 73 + "properties": { 74 + "subject": { 75 + "type": "string", 76 + "description": "the secondary subject that these records link to" 77 + }, 78 + "records": { 79 + "type": "array", 80 + "items": { 81 + "type": "ref", 82 + "ref": "#linkRecord" 83 + } 84 + } 85 + } 86 + }, 87 + "linkRecord": { 88 + "type": "object", 89 + "required": ["did", "collection", "rkey"], 90 + "description": "A record identifier consisting of a DID, collection, and record key", 91 + "properties": { 92 + "did": { 93 + "type": "string", 94 + "format": "did", 95 + "description": "the DID of the linking record's repository" 96 + }, 97 + "collection": { 98 + "type": "string", 99 + "format": "nsid", 100 + "description": "the collection of the linking record" 101 + }, 102 + "rkey": { 103 + "type": "string", 104 + "format": "record-key" 105 + } 106 + } 107 + } 108 + } 109 + }