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

Merge branch 'order_query'

merges https://tangled.org/microcosm.blue/microcosm-rs/pulls/6

+316 -161
+12 -3
constellation/src/server/mod.rs
··· 17 17 use tokio::task::spawn_blocking; 18 18 use tokio_util::sync::CancellationToken; 19 19 20 - use crate::storage::{LinkReader, StorageStats}; 20 + use crate::storage::{LinkReader, Order, StorageStats}; 21 21 use crate::{CountsByCount, Did, RecordId}; 22 22 23 23 mod acceptable; ··· 409 409 /// Set the max number of links to return per page of results 410 410 #[serde(default = "get_default_cursor_limit")] 411 411 limit: u64, 412 - // TODO: allow reverse (er, forward) order as well 412 + /// Allow returning links in reverse order (default: false) 413 + #[serde(default)] 414 + reverse: bool, 413 415 } 414 416 #[derive(Template, Serialize)] 415 417 #[template(path = "get-backlinks.html.j2")] ··· 455 457 }; 456 458 let path = format!(".{path}"); 457 459 460 + let order = if query.reverse { 461 + Order::OldestToNewest 462 + } else { 463 + Order::NewestToOldest 464 + }; 465 + 458 466 let paged = store 459 467 .get_links( 460 468 &query.subject, 461 469 collection, 462 470 &path, 471 + order, 463 472 limit, 464 473 until, 465 474 &filter_dids, ··· 508 517 from_dids: Option<String>, // comma separated: gross 509 518 #[serde(default = "get_default_cursor_limit")] 510 519 limit: u64, 511 - // TODO: allow reverse (er, forward) order as well 512 520 } 513 521 #[derive(Template, Serialize)] 514 522 #[template(path = "links.html.j2")] ··· 562 570 &query.target, 563 571 &query.collection, 564 572 &query.path, 573 + Order::NewestToOldest, 565 574 limit, 566 575 until, 567 576 &filter_dids,
+55 -53
constellation/src/storage/mem_store.rs
··· 1 1 use super::{ 2 - LinkReader, LinkStorage, PagedAppendingCollection, PagedOrderedCollection, StorageStats, 2 + LinkReader, LinkStorage, Order, PagedAppendingCollection, PagedOrderedCollection, StorageStats, 3 3 }; 4 4 use crate::{ActionableEvent, CountsByCount, Did, RecordId}; 5 5 use anyhow::Result; ··· 147 147 ) -> Result<PagedOrderedCollection<(String, u64, u64), String>> { 148 148 let data = self.0.lock().unwrap(); 149 149 let Some(paths) = data.targets.get(&Target::new(target)) else { 150 - return Ok(PagedOrderedCollection::default()); 150 + return Ok(PagedOrderedCollection::empty()); 151 151 }; 152 152 let Some(linkers) = paths.get(&Source::new(collection, path)) else { 153 - return Ok(PagedOrderedCollection::default()); 153 + return Ok(PagedOrderedCollection::empty()); 154 154 }; 155 155 156 156 let path_to_other = RecordPath::new(path_to_other); ··· 239 239 target: &str, 240 240 collection: &str, 241 241 path: &str, 242 + order: Order, 242 243 limit: u64, 243 - until: Option<u64>, 244 + until: Option<u64>, // paged iteration endpoint 244 245 filter_dids: &HashSet<Did>, 245 246 ) -> Result<PagedAppendingCollection<RecordId>> { 246 247 let data = self.0.lock().unwrap(); 247 248 let Some(paths) = data.targets.get(&Target::new(target)) else { 248 - return Ok(PagedAppendingCollection { 249 - version: (0, 0), 250 - items: Vec::new(), 251 - next: None, 252 - total: 0, 253 - }); 249 + return Ok(PagedAppendingCollection::empty()); 254 250 }; 255 251 let Some(did_rkeys) = paths.get(&Source::new(collection, path)) else { 256 - return Ok(PagedAppendingCollection { 257 - version: (0, 0), 258 - items: Vec::new(), 259 - next: None, 260 - total: 0, 261 - }); 252 + return Ok(PagedAppendingCollection::empty()); 262 253 }; 263 254 264 255 let did_rkeys: Vec<_> = if !filter_dids.is_empty() { ··· 275 266 did_rkeys.to_vec() 276 267 }; 277 268 278 - let total = did_rkeys.len(); 279 - let end = until 280 - .map(|u| std::cmp::min(u as usize, total)) 281 - .unwrap_or(total); 282 - let begin = end.saturating_sub(limit as usize); 283 - let next = if begin == 0 { None } else { Some(begin as u64) }; 269 + let total = did_rkeys.len() as u64; 284 270 285 - let alive = did_rkeys.iter().flatten().count(); 271 + // backlinks are stored oldest-to-newest (ascending index with increasing age) 272 + let (start, take, next_until) = match order { 273 + Order::OldestToNewest => { 274 + let start = until.unwrap_or(0); 275 + let next = start + limit + 1; 276 + let next_until = if next < total { Some(next) } else { None }; 277 + (start, limit, next_until) 278 + } 279 + Order::NewestToOldest => { 280 + let until = until.unwrap_or(total); 281 + match until.checked_sub(limit) { 282 + Some(s) if s > 0 => (s, limit, Some(s)), 283 + Some(s) => (s, limit, None), 284 + None => (0, until, None), 285 + } 286 + } 287 + }; 288 + 289 + let alive = did_rkeys.iter().flatten().count() as u64; 286 290 let gone = total - alive; 287 291 288 - let items: Vec<_> = did_rkeys[begin..end] 292 + let items = did_rkeys 289 293 .iter() 290 - .rev() 294 + .skip(start as usize) 295 + .take(take as usize) 291 296 .flatten() 292 297 .filter(|(did, _)| *data.dids.get(did).expect("did must be in dids")) 293 298 .map(|(did, rkey)| RecordId { 294 299 did: did.clone(), 295 300 rkey: rkey.0.clone(), 296 301 collection: collection.to_string(), 297 - }) 298 - .collect(); 302 + }); 303 + 304 + let items: Vec<_> = match order { 305 + Order::OldestToNewest => items.collect(), // links are stored oldest first 306 + Order::NewestToOldest => items.rev().collect(), 307 + }; 299 308 300 309 Ok(PagedAppendingCollection { 301 - version: (total as u64, gone as u64), 310 + version: (total, gone), 302 311 items, 303 - next, 304 - total: alive as u64, 312 + next: next_until, 313 + total: alive, 305 314 }) 306 315 } 307 316 ··· 315 324 ) -> Result<PagedAppendingCollection<Did>> { 316 325 let data = self.0.lock().unwrap(); 317 326 let Some(paths) = data.targets.get(&Target::new(target)) else { 318 - return Ok(PagedAppendingCollection { 319 - version: (0, 0), 320 - items: Vec::new(), 321 - next: None, 322 - total: 0, 323 - }); 327 + return Ok(PagedAppendingCollection::empty()); 324 328 }; 325 329 let Some(did_rkeys) = paths.get(&Source::new(collection, path)) else { 326 - return Ok(PagedAppendingCollection { 327 - version: (0, 0), 328 - items: Vec::new(), 329 - next: None, 330 - total: 0, 331 - }); 330 + return Ok(PagedAppendingCollection::empty()); 332 331 }; 333 332 334 333 let dids: Vec<Option<Did>> = { ··· 348 347 .collect() 349 348 }; 350 349 351 - let total = dids.len(); 352 - let end = until 353 - .map(|u| std::cmp::min(u as usize, total)) 354 - .unwrap_or(total); 355 - let begin = end.saturating_sub(limit as usize); 356 - let next = if begin == 0 { None } else { Some(begin as u64) }; 350 + let total = dids.len() as u64; 351 + let until = until.unwrap_or(total); 352 + let (start, take, next_until) = match until.checked_sub(limit) { 353 + Some(s) if s > 0 => (s, limit, Some(s)), 354 + Some(s) => (s, limit, None), 355 + None => (0, until, None), 356 + }; 357 357 358 - let alive = dids.iter().flatten().count(); 358 + let alive = dids.iter().flatten().count() as u64; 359 359 let gone = total - alive; 360 360 361 - let items: Vec<Did> = dids[begin..end] 361 + let items: Vec<Did> = dids 362 362 .iter() 363 + .skip(start as usize) 364 + .take(take as usize) 363 365 .rev() 364 366 .flatten() 365 367 .filter(|did| *data.dids.get(did).expect("did must be in dids")) ··· 367 369 .collect(); 368 370 369 371 Ok(PagedAppendingCollection { 370 - version: (total as u64, gone as u64), 372 + version: (total, gone), 371 373 items, 372 - next, 373 - total: alive as u64, 374 + next: next_until, 375 + total: alive, 374 376 }) 375 377 } 376 378
+195 -76
constellation/src/storage/mod.rs
··· 11 11 #[cfg(feature = "rocks")] 12 12 pub use rocks_store::RocksStorage; 13 13 14 - #[derive(Debug, PartialEq)] 14 + /// Ordering for paginated link queries 15 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 16 + pub enum Order { 17 + /// Newest links first (default) 18 + NewestToOldest, 19 + /// Oldest links first 20 + OldestToNewest, 21 + } 22 + 23 + #[derive(Debug, Default, PartialEq)] 15 24 pub struct PagedAppendingCollection<T> { 16 25 pub version: (u64, u64), // (collection length, deleted item count) // TODO: change to (total, active)? since dedups isn't "deleted" 17 26 pub items: Vec<T>, ··· 19 28 pub total: u64, 20 29 } 21 30 31 + impl<T> PagedAppendingCollection<T> { 32 + pub(crate) fn empty() -> Self { 33 + Self { 34 + version: (0, 0), 35 + items: Vec::new(), 36 + next: None, 37 + total: 0, 38 + } 39 + } 40 + } 41 + 22 42 /// A paged collection whose keys are sorted instead of indexed 23 43 /// 24 44 /// this has weaker guarantees than PagedAppendingCollection: it might 25 45 /// return a totally consistent snapshot. but it should avoid duplicates 26 46 /// and each page should at least be internally consistent. 27 - #[derive(Debug, PartialEq, Default)] 47 + #[derive(Debug, PartialEq)] 28 48 pub struct PagedOrderedCollection<T, K: Ord> { 29 49 pub items: Vec<T>, 30 50 pub next: Option<K>, 31 51 } 32 52 53 + impl<T, K: Ord> PagedOrderedCollection<T, K> { 54 + pub(crate) fn empty() -> Self { 55 + Self { 56 + items: Vec::new(), 57 + next: None, 58 + } 59 + } 60 + } 61 + 33 62 #[derive(Debug, Deserialize, Serialize, PartialEq)] 34 63 pub struct StorageStats { 35 64 /// estimate of how many accounts we've seen create links. the _subjects_ of any links are not represented here. ··· 82 111 83 112 fn get_distinct_did_count(&self, target: &str, collection: &str, path: &str) -> Result<u64>; 84 113 114 + #[allow(clippy::too_many_arguments)] 85 115 fn get_links( 86 116 &self, 87 117 target: &str, 88 118 collection: &str, 89 119 path: &str, 120 + order: Order, 90 121 limit: u64, 91 122 until: Option<u64>, 92 123 filter_dids: &HashSet<Did>, ··· 180 211 "a.com", 181 212 "app.t.c", 182 213 ".abc.uri", 214 + Order::NewestToOldest, 183 215 100, 184 216 None, 185 217 &HashSet::default() 186 218 )?, 187 - PagedAppendingCollection { 188 - version: (0, 0), 189 - items: vec![], 190 - next: None, 191 - total: 0, 192 - } 219 + PagedAppendingCollection::empty() 193 220 ); 194 221 assert_eq!( 195 222 storage.get_distinct_dids("a.com", "app.t.c", ".abc.uri", 100, None)?, 196 - PagedAppendingCollection { 197 - version: (0, 0), 198 - items: vec![], 199 - next: None, 200 - total: 0, 201 - } 223 + PagedAppendingCollection::empty() 202 224 ); 203 225 assert_eq!(storage.get_all_counts("bad-example.com")?, HashMap::new()); 204 226 assert_eq!( ··· 683 705 "a.com", 684 706 "app.t.c", 685 707 ".abc.uri", 708 + Order::NewestToOldest, 686 709 100, 687 710 None, 688 711 &HashSet::default() ··· 727 750 0, 728 751 )?; 729 752 } 730 - let links = 731 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 732 - let dids = storage.get_distinct_dids("a.com", "app.t.c", ".abc.uri", 2, None)?; 753 + 754 + let sub = "a.com"; 755 + let col = "app.t.c"; 756 + let path = ".abc.uri"; 757 + let order = Order::NewestToOldest; 758 + let dids_filter = HashSet::new(); 759 + 760 + // --- --- round one! --- --- // 761 + // all backlinks 762 + let links = storage.get_links(sub, col, path, order, 2, None, &dids_filter)?; 733 763 assert_eq!( 734 764 links, 735 765 PagedAppendingCollection { ··· 737 767 items: vec![ 738 768 RecordId { 739 769 did: "did:plc:asdf-5".into(), 740 - collection: "app.t.c".into(), 770 + collection: col.into(), 741 771 rkey: "asdf".into(), 742 772 }, 743 773 RecordId { 744 774 did: "did:plc:asdf-4".into(), 745 - collection: "app.t.c".into(), 775 + collection: col.into(), 746 776 rkey: "asdf".into(), 747 777 }, 748 778 ], ··· 750 780 total: 5, 751 781 } 752 782 ); 783 + // distinct dids 784 + let dids = storage.get_distinct_dids(sub, col, path, 2, None)?; 753 785 assert_eq!( 754 786 dids, 755 787 PagedAppendingCollection { ··· 759 791 total: 5, 760 792 } 761 793 ); 762 - let links = storage.get_links( 763 - "a.com", 764 - "app.t.c", 765 - ".abc.uri", 766 - 2, 767 - links.next, 768 - &HashSet::default(), 769 - )?; 770 - let dids = storage.get_distinct_dids("a.com", "app.t.c", ".abc.uri", 2, dids.next)?; 794 + 795 + // --- --- round two! --- --- // 796 + // all backlinks 797 + let links = storage.get_links(sub, col, path, order, 2, links.next, &dids_filter)?; 771 798 assert_eq!( 772 799 links, 773 800 PagedAppendingCollection { ··· 775 802 items: vec![ 776 803 RecordId { 777 804 did: "did:plc:asdf-3".into(), 778 - collection: "app.t.c".into(), 805 + collection: col.into(), 779 806 rkey: "asdf".into(), 780 807 }, 781 808 RecordId { 782 809 did: "did:plc:asdf-2".into(), 783 - collection: "app.t.c".into(), 810 + collection: col.into(), 784 811 rkey: "asdf".into(), 785 812 }, 786 813 ], ··· 788 815 total: 5, 789 816 } 790 817 ); 818 + // distinct dids 819 + let dids = storage.get_distinct_dids(sub, col, path, 2, dids.next)?; 791 820 assert_eq!( 792 821 dids, 793 822 PagedAppendingCollection { ··· 797 826 total: 5, 798 827 } 799 828 ); 800 - let links = storage.get_links( 801 - "a.com", 802 - "app.t.c", 803 - ".abc.uri", 804 - 2, 805 - links.next, 806 - &HashSet::default(), 807 - )?; 808 - let dids = storage.get_distinct_dids("a.com", "app.t.c", ".abc.uri", 2, dids.next)?; 829 + 830 + // --- --- round three! --- --- // 831 + // all backlinks 832 + let links = storage.get_links(sub, col, path, order, 2, links.next, &dids_filter)?; 809 833 assert_eq!( 810 834 links, 811 835 PagedAppendingCollection { 812 836 version: (5, 0), 813 837 items: vec![RecordId { 814 838 did: "did:plc:asdf-1".into(), 815 - collection: "app.t.c".into(), 839 + collection: col.into(), 816 840 rkey: "asdf".into(), 817 841 },], 818 842 next: None, 819 843 total: 5, 820 844 } 821 845 ); 846 + // distinct dids 847 + let dids = storage.get_distinct_dids(sub, col, path, 2, dids.next)?; 822 848 assert_eq!( 823 849 dids, 824 850 PagedAppendingCollection { ··· 828 854 total: 5, 829 855 } 830 856 ); 857 + 831 858 assert_stats(storage.get_stats()?, 5..=5, 1..=1, 5..=5); 832 859 }); 833 860 834 - test_each_storage!(get_filtered_links, |storage| { 861 + test_each_storage!(get_links_reverse_order, |storage| { 862 + for i in 1..=5 { 863 + storage.push( 864 + &ActionableEvent::CreateLinks { 865 + record_id: RecordId { 866 + did: format!("did:plc:asdf-{i}").into(), 867 + collection: "app.t.c".into(), 868 + rkey: "asdf".into(), 869 + }, 870 + links: vec![CollectedLink { 871 + target: Link::Uri("a.com".into()), 872 + path: ".abc.uri".into(), 873 + }], 874 + }, 875 + 0, 876 + )?; 877 + } 878 + 879 + // Test OldestToNewest order (oldest first) 880 + let links = storage.get_links( 881 + "a.com", 882 + "app.t.c", 883 + ".abc.uri", 884 + Order::OldestToNewest, 885 + 2, 886 + None, 887 + &HashSet::default(), 888 + )?; 889 + assert_eq!( 890 + links, 891 + PagedAppendingCollection { 892 + version: (5, 0), 893 + items: vec![ 894 + RecordId { 895 + did: "did:plc:asdf-1".into(), 896 + collection: "app.t.c".into(), 897 + rkey: "asdf".into(), 898 + }, 899 + RecordId { 900 + did: "did:plc:asdf-2".into(), 901 + collection: "app.t.c".into(), 902 + rkey: "asdf".into(), 903 + }, 904 + ], 905 + next: Some(3), 906 + total: 5, 907 + } 908 + ); 909 + // Test NewestToOldest order (newest first) 835 910 let links = storage.get_links( 836 911 "a.com", 837 912 "app.t.c", 838 913 ".abc.uri", 914 + Order::NewestToOldest, 839 915 2, 840 916 None, 841 - &HashSet::from([Did("did:plc:linker".to_string())]), 917 + &HashSet::default(), 842 918 )?; 843 919 assert_eq!( 844 920 links, 845 921 PagedAppendingCollection { 846 - version: (0, 0), 847 - items: vec![], 848 - next: None, 849 - total: 0, 922 + version: (5, 0), 923 + items: vec![ 924 + RecordId { 925 + did: "did:plc:asdf-5".into(), 926 + collection: "app.t.c".into(), 927 + rkey: "asdf".into(), 928 + }, 929 + RecordId { 930 + did: "did:plc:asdf-4".into(), 931 + collection: "app.t.c".into(), 932 + rkey: "asdf".into(), 933 + }, 934 + ], 935 + next: Some(3), 936 + total: 5, 850 937 } 851 938 ); 939 + assert_stats(storage.get_stats()?, 5..=5, 1..=1, 5..=5); 940 + }); 941 + 942 + test_each_storage!(get_filtered_links, |storage| { 943 + let links = storage.get_links( 944 + "a.com", 945 + "app.t.c", 946 + ".abc.uri", 947 + Order::NewestToOldest, 948 + 2, 949 + None, 950 + &HashSet::from([Did("did:plc:linker".to_string())]), 951 + )?; 952 + assert_eq!(links, PagedAppendingCollection::empty()); 852 953 853 954 storage.push( 854 955 &ActionableEvent::CreateLinks { ··· 869 970 "a.com", 870 971 "app.t.c", 871 972 ".abc.uri", 973 + Order::NewestToOldest, 872 974 2, 873 975 None, 874 976 &HashSet::from([Did("did:plc:linker".to_string())]), ··· 891 993 "a.com", 892 994 "app.t.c", 893 995 ".abc.uri", 996 + Order::NewestToOldest, 894 997 2, 895 998 None, 896 999 &HashSet::from([Did("did:plc:someone-else".to_string())]), 897 1000 )?; 898 - assert_eq!( 899 - links, 900 - PagedAppendingCollection { 901 - version: (0, 0), 902 - items: vec![], 903 - next: None, 904 - total: 0, 905 - } 906 - ); 1001 + assert_eq!(links, PagedAppendingCollection::empty()); 907 1002 908 1003 storage.push( 909 1004 &ActionableEvent::CreateLinks { ··· 938 1033 "a.com", 939 1034 "app.t.c", 940 1035 ".abc.uri", 1036 + Order::NewestToOldest, 941 1037 2, 942 1038 None, 943 1039 &HashSet::from([Did("did:plc:linker".to_string())]), ··· 967 1063 "a.com", 968 1064 "app.t.c", 969 1065 ".abc.uri", 1066 + Order::NewestToOldest, 970 1067 2, 971 1068 None, 972 1069 &HashSet::from([ ··· 999 1096 "a.com", 1000 1097 "app.t.c", 1001 1098 ".abc.uri", 1099 + Order::NewestToOldest, 1002 1100 2, 1003 1101 None, 1004 1102 &HashSet::from([Did("did:plc:someone-unknown".to_string())]), 1005 1103 )?; 1006 - assert_eq!( 1007 - links, 1008 - PagedAppendingCollection { 1009 - version: (0, 0), 1010 - items: vec![], 1011 - next: None, 1012 - total: 0, 1013 - } 1014 - ); 1104 + assert_eq!(links, PagedAppendingCollection::empty()); 1015 1105 }); 1016 1106 1017 1107 test_each_storage!(get_links_exact_multiple, |storage| { ··· 1031 1121 0, 1032 1122 )?; 1033 1123 } 1034 - let links = 1035 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 1124 + let links = storage.get_links( 1125 + "a.com", 1126 + "app.t.c", 1127 + ".abc.uri", 1128 + Order::NewestToOldest, 1129 + 2, 1130 + None, 1131 + &HashSet::default(), 1132 + )?; 1036 1133 assert_eq!( 1037 1134 links, 1038 1135 PagedAppendingCollection { ··· 1057 1154 "a.com", 1058 1155 "app.t.c", 1059 1156 ".abc.uri", 1157 + Order::NewestToOldest, 1060 1158 2, 1061 1159 links.next, 1062 1160 &HashSet::default(), ··· 1101 1199 0, 1102 1200 )?; 1103 1201 } 1104 - let links = 1105 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 1202 + let links = storage.get_links( 1203 + "a.com", 1204 + "app.t.c", 1205 + ".abc.uri", 1206 + Order::NewestToOldest, 1207 + 2, 1208 + None, 1209 + &HashSet::default(), 1210 + )?; 1106 1211 assert_eq!( 1107 1212 links, 1108 1213 PagedAppendingCollection { ··· 1141 1246 "a.com", 1142 1247 "app.t.c", 1143 1248 ".abc.uri", 1249 + Order::NewestToOldest, 1144 1250 2, 1145 1251 links.next, 1146 1252 &HashSet::default(), ··· 1185 1291 0, 1186 1292 )?; 1187 1293 } 1188 - let links = 1189 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 1294 + let links = storage.get_links( 1295 + "a.com", 1296 + "app.t.c", 1297 + ".abc.uri", 1298 + Order::NewestToOldest, 1299 + 2, 1300 + None, 1301 + &HashSet::default(), 1302 + )?; 1190 1303 assert_eq!( 1191 1304 links, 1192 1305 PagedAppendingCollection { ··· 1219 1332 "a.com", 1220 1333 "app.t.c", 1221 1334 ".abc.uri", 1335 + Order::NewestToOldest, 1222 1336 2, 1223 1337 links.next, 1224 1338 &HashSet::default(), ··· 1256 1370 0, 1257 1371 )?; 1258 1372 } 1259 - let links = 1260 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 1373 + let links = storage.get_links( 1374 + "a.com", 1375 + "app.t.c", 1376 + ".abc.uri", 1377 + Order::NewestToOldest, 1378 + 2, 1379 + None, 1380 + &HashSet::default(), 1381 + )?; 1261 1382 assert_eq!( 1262 1383 links, 1263 1384 PagedAppendingCollection { ··· 1286 1407 "a.com", 1287 1408 "app.t.c", 1288 1409 ".abc.uri", 1410 + Order::NewestToOldest, 1289 1411 2, 1290 1412 links.next, 1291 1413 &HashSet::default(), ··· 1372 1494 &HashSet::new(), 1373 1495 &HashSet::new(), 1374 1496 )?, 1375 - PagedOrderedCollection { 1376 - items: vec![], 1377 - next: None, 1378 - } 1497 + PagedOrderedCollection::empty() 1379 1498 ); 1380 1499 }); 1381 1500
+41 -25
constellation/src/storage/rocks_store.rs
··· 1 1 use super::{ 2 - ActionableEvent, LinkReader, LinkStorage, PagedAppendingCollection, PagedOrderedCollection, 3 - StorageStats, 2 + ActionableEvent, LinkReader, LinkStorage, Order, PagedAppendingCollection, 3 + PagedOrderedCollection, StorageStats, 4 4 }; 5 5 use crate::{CountsByCount, Did, RecordId}; 6 6 use anyhow::{bail, Result}; ··· 960 960 961 961 let Some(target_id) = self.target_id_table.get_id_val(&self.db, &target_key)? else { 962 962 eprintln!("nothin doin for this target, {target_key:?}"); 963 - return Ok(Default::default()); 963 + return Ok(PagedOrderedCollection::empty()); 964 964 }; 965 965 966 966 let filter_did_ids: HashMap<DidId, bool> = filter_dids ··· 1127 1127 target: &str, 1128 1128 collection: &str, 1129 1129 path: &str, 1130 + order: Order, 1130 1131 limit: u64, 1131 1132 until: Option<u64>, 1132 1133 filter_dids: &HashSet<Did>, ··· 1138 1139 ); 1139 1140 1140 1141 let Some(target_id) = self.target_id_table.get_id_val(&self.db, &target_key)? else { 1141 - return Ok(PagedAppendingCollection { 1142 - version: (0, 0), 1143 - items: Vec::new(), 1144 - next: None, 1145 - total: 0, 1146 - }); 1142 + return Ok(PagedAppendingCollection::empty()); 1147 1143 }; 1148 1144 1149 1145 let mut linkers = self.get_target_linkers(&target_id)?; ··· 1167 1163 1168 1164 let (alive, gone) = linkers.count(); 1169 1165 let total = alive + gone; 1170 - let end = until.map(|u| std::cmp::min(u, total)).unwrap_or(total) as usize; 1171 - let begin = end.saturating_sub(limit as usize); 1172 - let next = if begin == 0 { None } else { Some(begin as u64) }; 1173 1166 1174 - let did_id_rkeys = linkers.0[begin..end].iter().rev().collect::<Vec<_>>(); 1167 + let (start, take, next_until) = match order { 1168 + // OldestToNewest: start from the beginning, paginate forward 1169 + Order::OldestToNewest => { 1170 + let start = until.unwrap_or(0); 1171 + let next = start + limit + 1; 1172 + let next_until = if next < total { Some(next) } else { None }; 1173 + (start, limit, next_until) 1174 + } 1175 + // NewestToOldest: start from the end, paginate backward 1176 + Order::NewestToOldest => { 1177 + let until = until.unwrap_or(total); 1178 + match until.checked_sub(limit) { 1179 + Some(s) if s > 0 => (s, limit, Some(s)), 1180 + Some(s) => (s, limit, None), 1181 + None => (0, until, None), 1182 + } 1183 + } 1184 + }; 1185 + 1186 + let did_id_rkeys = linkers.0.iter().skip(start as usize).take(take as usize); 1187 + let did_id_rkeys: Vec<_> = match order { 1188 + Order::OldestToNewest => did_id_rkeys.collect(), 1189 + Order::NewestToOldest => did_id_rkeys.rev().collect(), 1190 + }; 1175 1191 1176 1192 let mut items = Vec::with_capacity(did_id_rkeys.len()); 1177 1193 // TODO: use get-many (or multi-get or whatever it's called) ··· 1201 1217 Ok(PagedAppendingCollection { 1202 1218 version: (total, gone), 1203 1219 items, 1204 - next, 1220 + next: next_until, 1205 1221 total: alive, 1206 1222 }) 1207 1223 } ··· 1221 1237 ); 1222 1238 1223 1239 let Some(target_id) = self.target_id_table.get_id_val(&self.db, &target_key)? else { 1224 - return Ok(PagedAppendingCollection { 1225 - version: (0, 0), 1226 - items: Vec::new(), 1227 - next: None, 1228 - total: 0, 1229 - }); 1240 + return Ok(PagedAppendingCollection::empty()); 1230 1241 }; 1231 1242 1232 1243 let linkers = self.get_distinct_target_linkers(&target_id)?; 1233 1244 1234 1245 let (alive, gone) = linkers.count(); 1235 1246 let total = alive + gone; 1236 - let end = until.map(|u| std::cmp::min(u, total)).unwrap_or(total) as usize; 1237 - let begin = end.saturating_sub(limit as usize); 1238 - let next = if begin == 0 { None } else { Some(begin as u64) }; 1247 + 1248 + let until = until.unwrap_or(total); 1249 + let (start, take, next_until) = match until.checked_sub(limit) { 1250 + Some(s) if s > 0 => (s, limit, Some(s)), 1251 + Some(s) => (s, limit, None), 1252 + None => (0, until, None), 1253 + }; 1239 1254 1240 - let did_id_rkeys = linkers.0[begin..end].iter().rev().collect::<Vec<_>>(); 1255 + let did_id_rkeys = linkers.0.iter().skip(start as usize).take(take as usize); 1256 + let did_id_rkeys: Vec<_> = did_id_rkeys.rev().collect(); 1241 1257 1242 1258 let mut items = Vec::with_capacity(did_id_rkeys.len()); 1243 1259 // TODO: use get-many (or multi-get or whatever it's called) ··· 1263 1279 Ok(PagedAppendingCollection { 1264 1280 version: (total, gone), 1265 1281 items, 1266 - next, 1282 + next: next_until, 1267 1283 total: alive, 1268 1284 }) 1269 1285 }
+4
constellation/templates/base.html.j2
··· 40 40 padding: 0.5em 0.3em; 41 41 max-width: 100%; 42 42 } 43 + pre.code input { 44 + margin: 0; 45 + padding: 0; 46 + } 43 47 .stat { 44 48 color: #f90; 45 49 font-size: 1.618rem;
+2 -1
constellation/templates/get-backlinks.html.j2
··· 6 6 7 7 {% block content %} 8 8 9 - {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit) %} 9 + {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit, query.reverse) %} 10 10 11 11 <h2> 12 12 Links to <code>{{ query.subject }}</code> ··· 40 40 <input type="hidden" name="did" value="{{ did }}" /> 41 41 {% endfor %} 42 42 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 43 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 43 44 <button type="submit">next page&hellip;</button> 44 45 </form> 45 46 {% else %}
+4 -1
constellation/templates/hello.html.j2
··· 49 49 <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li> 50 50 <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> 51 51 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 52 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 52 53 </ul> 53 54 54 55 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 55 - {% call try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16) %} 56 + {% call 57 + try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16, false) %} 56 58 57 59 58 60 <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToManyCounts</code></h3> ··· 96 98 <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> 97 99 <li><p><code>from_dids</code> [deprecated]: optional. Use <code>did</code> instead. Example: <code>from_dids=did:plc:vc7f4oafdgxsihk4cry2xpze,did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 98 100 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 101 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 99 102 </ul> 100 103 101 104 <p style="margin-bottom: 0"><strong>Try it:</strong></p>
+3 -2
constellation/templates/try-it-macros.html.j2
··· 1 - {% macro get_backlinks(subject, source, dids, limit) %} 1 + {% macro get_backlinks(subject, source, dids, limit, reverse) %} 2 2 <form method="get" action="/xrpc/blue.microcosm.links.getBacklinks"> 3 3 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getBacklinks 4 4 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 6 6 {%- for did in dids %}{% if !did.is_empty() %} 7 7 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 8 8 <span id="did-placeholder"></span> <button id="add-did">+ did filter</button> 9 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 9 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 10 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"><button type="submit">get links</button></pre> 10 11 </form> 11 12 <script> 12 13 const addDidButton = document.getElementById('add-did');