Add support for reverse ordering in link queries (Issue #1) #6

open
opened by maxh.site targeting main from maxh.site/microcosm-rs: order_query

The following adds support for reverse-chronological ordering when fetching back-links.

We add a non-required "reverse" query parameter that allows reversing the default order of returned back-links.

Supports both current storage backends (mem_store and rocks_store).

+3 -19
constellation/src/bin/main.rs
··· 26 #[arg(long)] 27 #[clap(default_value = "0.0.0.0:6789")] 28 bind: SocketAddr, 29 - /// optionally disable the metrics server 30 - #[arg(long)] 31 - #[clap(default_value_t = false)] 32 - collect_metrics: bool, 33 /// metrics server's listen address 34 #[arg(long)] 35 #[clap(default_value = "0.0.0.0:8765")] ··· 96 let bind = args.bind; 97 let metrics_bind = args.bind_metrics; 98 99 - let collect_metrics = args.collect_metrics; 100 let stay_alive = CancellationToken::new(); 101 102 match args.backend { ··· 107 stream, 108 bind, 109 metrics_bind, 110 - collect_metrics, 111 stay_alive, 112 ), 113 #[cfg(feature = "rocks")] ··· 142 stream, 143 bind, 144 metrics_bind, 145 - collect_metrics, 146 stay_alive, 147 ); 148 eprintln!("run finished: {r:?}"); ··· 154 } 155 } 156 157 - #[allow(clippy::too_many_lines)] 158 - #[allow(clippy::too_many_arguments)] 159 fn run( 160 mut storage: impl LinkStorage, 161 fixture: Option<PathBuf>, ··· 163 stream: String, 164 bind: SocketAddr, 165 metrics_bind: SocketAddr, 166 - collect_metrics: bool, 167 stay_alive: CancellationToken, 168 ) -> Result<()> { 169 ctrlc::set_handler({ ··· 208 .build() 209 .expect("axum startup") 210 .block_on(async { 211 - // Install metrics server only if requested 212 - if collect_metrics { 213 - install_metrics_server(metrics_bind)?; 214 - } 215 serve(readable, bind, staying_alive).await 216 }) 217 .unwrap(); ··· 219 } 220 }); 221 222 - // only spawn monitoring thread if the metrics server is running 223 - if collect_metrics { 224 - s.spawn(move || { // monitor thread 225 let stay_alive = stay_alive.clone(); 226 let check_alive = stay_alive.clone(); 227 ··· 273 } 274 } 275 stay_alive.drop_guard(); 276 - }); 277 - } 278 }); 279 280 println!("byeeee");
··· 26 #[arg(long)] 27 #[clap(default_value = "0.0.0.0:6789")] 28 bind: SocketAddr, 29 /// metrics server's listen address 30 #[arg(long)] 31 #[clap(default_value = "0.0.0.0:8765")] ··· 92 let bind = args.bind; 93 let metrics_bind = args.bind_metrics; 94 95 let stay_alive = CancellationToken::new(); 96 97 match args.backend { ··· 102 stream, 103 bind, 104 metrics_bind, 105 stay_alive, 106 ), 107 #[cfg(feature = "rocks")] ··· 136 stream, 137 bind, 138 metrics_bind, 139 stay_alive, 140 ); 141 eprintln!("run finished: {r:?}"); ··· 147 } 148 } 149 150 fn run( 151 mut storage: impl LinkStorage, 152 fixture: Option<PathBuf>, ··· 154 stream: String, 155 bind: SocketAddr, 156 metrics_bind: SocketAddr, 157 stay_alive: CancellationToken, 158 ) -> Result<()> { 159 ctrlc::set_handler({ ··· 198 .build() 199 .expect("axum startup") 200 .block_on(async { 201 + install_metrics_server(metrics_bind)?; 202 serve(readable, bind, staying_alive).await 203 }) 204 .unwrap(); ··· 206 } 207 }); 208 209 + s.spawn(move || { // monitor thread 210 let stay_alive = stay_alive.clone(); 211 let check_alive = stay_alive.clone(); 212 ··· 258 } 259 } 260 stay_alive.drop_guard(); 261 + }); 262 }); 263 264 println!("byeeee");
+14 -5
constellation/src/server/mod.rs
··· 25 26 use acceptable::{acceptable, ExtractAccept}; 27 28 - const DEFAULT_CURSOR_LIMIT: u64 = 100; 29 - const DEFAULT_CURSOR_LIMIT_MAX: u64 = 1000; 30 31 fn get_default_cursor_limit() -> u64 { 32 DEFAULT_CURSOR_LIMIT ··· 239 /// Set the max number of links to return per page of results 240 #[serde(default = "get_default_cursor_limit")] 241 limit: u64, 242 } 243 #[derive(Serialize)] 244 struct OtherSubjectCount { ··· 301 collection, 302 &path, 303 &path_to_other, 304 limit, 305 cursor_key, 306 &filter_dids, ··· 409 /// Set the max number of links to return per page of results 410 #[serde(default = "get_default_cursor_limit")] 411 limit: u64, 412 - // TODO: allow reverse (er, forward) order as well 413 } 414 #[derive(Template, Serialize)] 415 #[template(path = "get-backlinks.html.j2")] ··· 460 &query.subject, 461 collection, 462 &path, 463 limit, 464 until, 465 &filter_dids, ··· 508 from_dids: Option<String>, // comma separated: gross 509 #[serde(default = "get_default_cursor_limit")] 510 limit: u64, 511 - // TODO: allow reverse (er, forward) order as well 512 } 513 #[derive(Template, Serialize)] 514 #[template(path = "links.html.j2")] ··· 562 &query.target, 563 &query.collection, 564 &query.path, 565 limit, 566 until, 567 &filter_dids, ··· 594 path: String, 595 cursor: Option<OpaqueApiCursor>, 596 limit: Option<u64>, 597 - // TODO: allow reverse (er, forward) order as well 598 } 599 #[derive(Template, Serialize)] 600 #[template(path = "dids.html.j2")]
··· 25 26 use acceptable::{acceptable, ExtractAccept}; 27 28 + const DEFAULT_CURSOR_LIMIT: u64 = 16; 29 + const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100; 30 31 fn get_default_cursor_limit() -> u64 { 32 DEFAULT_CURSOR_LIMIT ··· 239 /// Set the max number of links to return per page of results 240 #[serde(default = "get_default_cursor_limit")] 241 limit: u64, 242 + /// Allow returning links in reverse order (default: false) 243 + #[serde(default)] 244 + reverse: bool, 245 } 246 #[derive(Serialize)] 247 struct OtherSubjectCount { ··· 304 collection, 305 &path, 306 &path_to_other, 307 + query.reverse, 308 limit, 309 cursor_key, 310 &filter_dids, ··· 413 /// Set the max number of links to return per page of results 414 #[serde(default = "get_default_cursor_limit")] 415 limit: u64, 416 + /// Allow returning links in reverse order (default: false) 417 + #[serde(default)] 418 + reverse: bool, 419 } 420 #[derive(Template, Serialize)] 421 #[template(path = "get-backlinks.html.j2")] ··· 466 &query.subject, 467 collection, 468 &path, 469 + query.reverse, 470 limit, 471 until, 472 &filter_dids, ··· 515 from_dids: Option<String>, // comma separated: gross 516 #[serde(default = "get_default_cursor_limit")] 517 limit: u64, 518 + /// Allow returning links in reverse order (default: false) 519 + #[serde(default)] 520 + reverse: bool, 521 } 522 #[derive(Template, Serialize)] 523 #[template(path = "links.html.j2")] ··· 571 &query.target, 572 &query.collection, 573 &query.path, 574 + query.reverse, 575 limit, 576 until, 577 &filter_dids, ··· 604 path: String, 605 cursor: Option<OpaqueApiCursor>, 606 limit: Option<u64>, 607 } 608 #[derive(Template, Serialize)] 609 #[template(path = "dids.html.j2")]
+43 -11
constellation/src/storage/mem_store.rs
··· 140 collection: &str, 141 path: &str, 142 path_to_other: &str, 143 limit: u64, 144 after: Option<String>, 145 filter_dids: &HashSet<Did>, ··· 157 let filter_to_targets: HashSet<Target> = 158 HashSet::from_iter(filter_to_targets.iter().map(|s| Target::new(s))); 159 160 - let mut grouped_counts: HashMap<Target, (u64, HashSet<Did>)> = HashMap::new(); 161 - for (did, rkey) in linkers.iter().flatten().cloned() { 162 if !filter_dids.is_empty() && !filter_dids.contains(&did) { 163 continue; 164 } ··· 184 .take(1) 185 .next() 186 { 187 - let e = grouped_counts.entry(fwd_target.clone()).or_default(); 188 e.0 += 1; 189 e.1.insert(did.clone()); 190 } 191 } 192 let mut items: Vec<(String, u64, u64)> = grouped_counts 193 .iter() 194 - .map(|(k, (n, u))| (k.0.clone(), *n, u.len() as u64)) 195 .collect(); 196 - items.sort(); 197 items = items 198 .into_iter() 199 .skip_while(|(t, _, _)| after.as_ref().map(|a| t <= a).unwrap_or(false)) ··· 239 target: &str, 240 collection: &str, 241 path: &str, 242 limit: u64, 243 until: Option<u64>, 244 filter_dids: &HashSet<Did>, ··· 276 }; 277 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) }; 284 285 let alive = did_rkeys.iter().flatten().count(); 286 let gone = total - alive; 287 288 - let items: Vec<_> = did_rkeys[begin..end] 289 .iter() 290 .rev() 291 .flatten() ··· 296 collection: collection.to_string(), 297 }) 298 .collect(); 299 300 Ok(PagedAppendingCollection { 301 version: (total as u64, gone as u64),
··· 140 collection: &str, 141 path: &str, 142 path_to_other: &str, 143 + reverse: bool, 144 limit: u64, 145 after: Option<String>, 146 filter_dids: &HashSet<Did>, ··· 158 let filter_to_targets: HashSet<Target> = 159 HashSet::from_iter(filter_to_targets.iter().map(|s| Target::new(s))); 160 161 + // the last type field here acts as an index to allow keeping track of the order in which 162 + // we encountred single elements 163 + let mut grouped_counts: HashMap<Target, (u64, HashSet<Did>, usize)> = HashMap::new(); 164 + for (idx, (did, rkey)) in linkers.iter().flatten().cloned().enumerate() { 165 if !filter_dids.is_empty() && !filter_dids.contains(&did) { 166 continue; 167 } ··· 187 .take(1) 188 .next() 189 { 190 + let e = 191 + grouped_counts 192 + .entry(fwd_target.clone()) 193 + .or_insert((0, HashSet::new(), idx)); 194 e.0 += 1; 195 e.1.insert(did.clone()); 196 } 197 } 198 let mut items: Vec<(String, u64, u64)> = grouped_counts 199 .iter() 200 + .map(|(k, (n, u, _))| (k.0.clone(), *n, u.len() as u64)) 201 .collect(); 202 + // sort in reverse order to show entries from oldest to newest 203 + if reverse { 204 + items.sort_by(|a, b| b.cmp(a)); 205 + } else { 206 + items.sort(); 207 + } 208 items = items 209 .into_iter() 210 .skip_while(|(t, _, _)| after.as_ref().map(|a| t <= a).unwrap_or(false)) ··· 250 target: &str, 251 collection: &str, 252 path: &str, 253 + reverse: bool, 254 limit: u64, 255 until: Option<u64>, 256 filter_dids: &HashSet<Did>, ··· 288 }; 289 290 let total = did_rkeys.len(); 291 + 292 + let begin: usize; 293 + let end: usize; 294 + let next: Option<u64>; 295 + 296 + if reverse { 297 + begin = until.map(|u| (u) as usize).unwrap_or(0); 298 + end = std::cmp::min(begin + limit as usize, total); 299 + 300 + next = if end < total { 301 + Some(end as u64 + 1) 302 + } else { 303 + None 304 + }; 305 + } else { 306 + end = until 307 + .map(|u| std::cmp::min(u as usize, total)) 308 + .unwrap_or(total); 309 + begin = end.saturating_sub(limit as usize); 310 + next = if begin == 0 { None } else { Some(begin as u64) }; 311 + } 312 313 let alive = did_rkeys.iter().flatten().count(); 314 let gone = total - alive; 315 316 + let mut items: Vec<_> = did_rkeys[begin..end] 317 .iter() 318 .rev() 319 .flatten() ··· 324 collection: collection.to_string(), 325 }) 326 .collect(); 327 + 328 + if reverse { 329 + items.reverse(); 330 + } 331 332 Ok(PagedAppendingCollection { 333 version: (total as u64, gone as u64),
+247 -10
constellation/src/storage/mod.rs
··· 72 collection: &str, 73 path: &str, 74 path_to_other: &str, 75 limit: u64, 76 after: Option<String>, 77 filter_dids: &HashSet<Did>, ··· 87 target: &str, 88 collection: &str, 89 path: &str, 90 limit: u64, 91 until: Option<u64>, 92 filter_dids: &HashSet<Did>, ··· 180 "a.com", 181 "app.t.c", 182 ".abc.uri", 183 100, 184 None, 185 &HashSet::default() ··· 683 "a.com", 684 "app.t.c", 685 ".abc.uri", 686 100, 687 None, 688 &HashSet::default() ··· 727 0, 728 )?; 729 } 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)?; 733 assert_eq!( 734 links, ··· 763 "a.com", 764 "app.t.c", 765 ".abc.uri", 766 2, 767 links.next, 768 &HashSet::default(), ··· 801 "a.com", 802 "app.t.c", 803 ".abc.uri", 804 2, 805 links.next, 806 &HashSet::default(), ··· 831 assert_stats(storage.get_stats()?, 5..=5, 1..=1, 5..=5); 832 }); 833 834 test_each_storage!(get_filtered_links, |storage| { 835 let links = storage.get_links( 836 "a.com", 837 "app.t.c", 838 ".abc.uri", 839 2, 840 None, 841 &HashSet::from([Did("did:plc:linker".to_string())]), ··· 869 "a.com", 870 "app.t.c", 871 ".abc.uri", 872 2, 873 None, 874 &HashSet::from([Did("did:plc:linker".to_string())]), ··· 891 "a.com", 892 "app.t.c", 893 ".abc.uri", 894 2, 895 None, 896 &HashSet::from([Did("did:plc:someone-else".to_string())]), ··· 938 "a.com", 939 "app.t.c", 940 ".abc.uri", 941 2, 942 None, 943 &HashSet::from([Did("did:plc:linker".to_string())]), ··· 967 "a.com", 968 "app.t.c", 969 ".abc.uri", 970 2, 971 None, 972 &HashSet::from([ ··· 999 "a.com", 1000 "app.t.c", 1001 ".abc.uri", 1002 2, 1003 None, 1004 &HashSet::from([Did("did:plc:someone-unknown".to_string())]), ··· 1031 0, 1032 )?; 1033 } 1034 - let links = 1035 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 1036 assert_eq!( 1037 links, 1038 PagedAppendingCollection { ··· 1057 "a.com", 1058 "app.t.c", 1059 ".abc.uri", 1060 2, 1061 links.next, 1062 &HashSet::default(), ··· 1101 0, 1102 )?; 1103 } 1104 - let links = 1105 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 1106 assert_eq!( 1107 links, 1108 PagedAppendingCollection { ··· 1141 "a.com", 1142 "app.t.c", 1143 ".abc.uri", 1144 2, 1145 links.next, 1146 &HashSet::default(), ··· 1185 0, 1186 )?; 1187 } 1188 - let links = 1189 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 1190 assert_eq!( 1191 links, 1192 PagedAppendingCollection { ··· 1219 "a.com", 1220 "app.t.c", 1221 ".abc.uri", 1222 2, 1223 links.next, 1224 &HashSet::default(), ··· 1256 0, 1257 )?; 1258 } 1259 - let links = 1260 - storage.get_links("a.com", "app.t.c", ".abc.uri", 2, None, &HashSet::default())?; 1261 assert_eq!( 1262 links, 1263 PagedAppendingCollection { ··· 1286 "a.com", 1287 "app.t.c", 1288 ".abc.uri", 1289 2, 1290 links.next, 1291 &HashSet::default(), ··· 1367 "a.b.c", 1368 ".d.e", 1369 ".f.g", 1370 10, 1371 None, 1372 &HashSet::new(), ··· 1410 "app.t.c", 1411 ".abc.uri", 1412 ".def.uri", 1413 10, 1414 None, 1415 &HashSet::new(), ··· 1509 "app.t.c", 1510 ".abc.uri", 1511 ".def.uri", 1512 10, 1513 None, 1514 &HashSet::new(), ··· 1525 "app.t.c", 1526 ".abc.uri", 1527 ".def.uri", 1528 10, 1529 None, 1530 &HashSet::from_iter([Did("did:plc:fdsa".to_string())]), ··· 1541 "app.t.c", 1542 ".abc.uri", 1543 ".def.uri", 1544 10, 1545 None, 1546 &HashSet::new(), ··· 1551 next: None, 1552 } 1553 ); 1554 }); 1555 }
··· 72 collection: &str, 73 path: &str, 74 path_to_other: &str, 75 + reverse: bool, 76 limit: u64, 77 after: Option<String>, 78 filter_dids: &HashSet<Did>, ··· 88 target: &str, 89 collection: &str, 90 path: &str, 91 + reverse: bool, 92 limit: u64, 93 until: Option<u64>, 94 filter_dids: &HashSet<Did>, ··· 182 "a.com", 183 "app.t.c", 184 ".abc.uri", 185 + false, 186 100, 187 None, 188 &HashSet::default() ··· 686 "a.com", 687 "app.t.c", 688 ".abc.uri", 689 + false, 690 100, 691 None, 692 &HashSet::default() ··· 731 0, 732 )?; 733 } 734 + let links = storage.get_links( 735 + "a.com", 736 + "app.t.c", 737 + ".abc.uri", 738 + false, 739 + 2, 740 + None, 741 + &HashSet::default(), 742 + )?; 743 let dids = storage.get_distinct_dids("a.com", "app.t.c", ".abc.uri", 2, None)?; 744 assert_eq!( 745 links, ··· 774 "a.com", 775 "app.t.c", 776 ".abc.uri", 777 + false, 778 2, 779 links.next, 780 &HashSet::default(), ··· 813 "a.com", 814 "app.t.c", 815 ".abc.uri", 816 + false, 817 2, 818 links.next, 819 &HashSet::default(), ··· 844 assert_stats(storage.get_stats()?, 5..=5, 1..=1, 5..=5); 845 }); 846 847 + test_each_storage!(get_links_reverse_order, |storage| { 848 + for i in 1..=5 { 849 + storage.push( 850 + &ActionableEvent::CreateLinks { 851 + record_id: RecordId { 852 + did: format!("did:plc:asdf-{i}").into(), 853 + collection: "app.t.c".into(), 854 + rkey: "asdf".into(), 855 + }, 856 + links: vec![CollectedLink { 857 + target: Link::Uri("a.com".into()), 858 + path: ".abc.uri".into(), 859 + }], 860 + }, 861 + 0, 862 + )?; 863 + } 864 + 865 + // Test reverse: true (oldest first) 866 + let links = storage.get_links( 867 + "a.com", 868 + "app.t.c", 869 + ".abc.uri", 870 + true, 871 + 2, 872 + None, 873 + &HashSet::default(), 874 + )?; 875 + assert_eq!( 876 + links, 877 + PagedAppendingCollection { 878 + version: (5, 0), 879 + items: vec![ 880 + RecordId { 881 + did: "did:plc:asdf-1".into(), 882 + collection: "app.t.c".into(), 883 + rkey: "asdf".into(), 884 + }, 885 + RecordId { 886 + did: "did:plc:asdf-2".into(), 887 + collection: "app.t.c".into(), 888 + rkey: "asdf".into(), 889 + }, 890 + ], 891 + next: Some(3), 892 + total: 5, 893 + } 894 + ); 895 + // Test reverse: false (newest first) 896 + let links = storage.get_links( 897 + "a.com", 898 + "app.t.c", 899 + ".abc.uri", 900 + false, 901 + 2, 902 + None, 903 + &HashSet::default(), 904 + )?; 905 + assert_eq!( 906 + links, 907 + PagedAppendingCollection { 908 + version: (5, 0), 909 + items: vec![ 910 + RecordId { 911 + did: "did:plc:asdf-5".into(), 912 + collection: "app.t.c".into(), 913 + rkey: "asdf".into(), 914 + }, 915 + RecordId { 916 + did: "did:plc:asdf-4".into(), 917 + collection: "app.t.c".into(), 918 + rkey: "asdf".into(), 919 + }, 920 + ], 921 + next: Some(3), 922 + total: 5, 923 + } 924 + ); 925 + assert_stats(storage.get_stats()?, 5..=5, 1..=1, 5..=5); 926 + }); 927 + 928 test_each_storage!(get_filtered_links, |storage| { 929 let links = storage.get_links( 930 "a.com", 931 "app.t.c", 932 ".abc.uri", 933 + false, 934 2, 935 None, 936 &HashSet::from([Did("did:plc:linker".to_string())]), ··· 964 "a.com", 965 "app.t.c", 966 ".abc.uri", 967 + false, 968 2, 969 None, 970 &HashSet::from([Did("did:plc:linker".to_string())]), ··· 987 "a.com", 988 "app.t.c", 989 ".abc.uri", 990 + false, 991 2, 992 None, 993 &HashSet::from([Did("did:plc:someone-else".to_string())]), ··· 1035 "a.com", 1036 "app.t.c", 1037 ".abc.uri", 1038 + false, 1039 2, 1040 None, 1041 &HashSet::from([Did("did:plc:linker".to_string())]), ··· 1065 "a.com", 1066 "app.t.c", 1067 ".abc.uri", 1068 + false, 1069 2, 1070 None, 1071 &HashSet::from([ ··· 1098 "a.com", 1099 "app.t.c", 1100 ".abc.uri", 1101 + false, 1102 2, 1103 None, 1104 &HashSet::from([Did("did:plc:someone-unknown".to_string())]), ··· 1131 0, 1132 )?; 1133 } 1134 + let links = storage.get_links( 1135 + "a.com", 1136 + "app.t.c", 1137 + ".abc.uri", 1138 + false, 1139 + 2, 1140 + None, 1141 + &HashSet::default(), 1142 + )?; 1143 assert_eq!( 1144 links, 1145 PagedAppendingCollection { ··· 1164 "a.com", 1165 "app.t.c", 1166 ".abc.uri", 1167 + false, 1168 2, 1169 links.next, 1170 &HashSet::default(), ··· 1209 0, 1210 )?; 1211 } 1212 + let links = storage.get_links( 1213 + "a.com", 1214 + "app.t.c", 1215 + ".abc.uri", 1216 + false, 1217 + 2, 1218 + None, 1219 + &HashSet::default(), 1220 + )?; 1221 assert_eq!( 1222 links, 1223 PagedAppendingCollection { ··· 1256 "a.com", 1257 "app.t.c", 1258 ".abc.uri", 1259 + false, 1260 2, 1261 links.next, 1262 &HashSet::default(), ··· 1301 0, 1302 )?; 1303 } 1304 + let links = storage.get_links( 1305 + "a.com", 1306 + "app.t.c", 1307 + ".abc.uri", 1308 + false, 1309 + 2, 1310 + None, 1311 + &HashSet::default(), 1312 + )?; 1313 assert_eq!( 1314 links, 1315 PagedAppendingCollection { ··· 1342 "a.com", 1343 "app.t.c", 1344 ".abc.uri", 1345 + false, 1346 2, 1347 links.next, 1348 &HashSet::default(), ··· 1380 0, 1381 )?; 1382 } 1383 + let links = storage.get_links( 1384 + "a.com", 1385 + "app.t.c", 1386 + ".abc.uri", 1387 + false, 1388 + 2, 1389 + None, 1390 + &HashSet::default(), 1391 + )?; 1392 assert_eq!( 1393 links, 1394 PagedAppendingCollection { ··· 1417 "a.com", 1418 "app.t.c", 1419 ".abc.uri", 1420 + false, 1421 2, 1422 links.next, 1423 &HashSet::default(), ··· 1499 "a.b.c", 1500 ".d.e", 1501 ".f.g", 1502 + false, 1503 10, 1504 None, 1505 &HashSet::new(), ··· 1543 "app.t.c", 1544 ".abc.uri", 1545 ".def.uri", 1546 + false, 1547 10, 1548 None, 1549 &HashSet::new(), ··· 1643 "app.t.c", 1644 ".abc.uri", 1645 ".def.uri", 1646 + false, 1647 10, 1648 None, 1649 &HashSet::new(), ··· 1660 "app.t.c", 1661 ".abc.uri", 1662 ".def.uri", 1663 + false, 1664 10, 1665 None, 1666 &HashSet::from_iter([Did("did:plc:fdsa".to_string())]), ··· 1677 "app.t.c", 1678 ".abc.uri", 1679 ".def.uri", 1680 + false, 1681 10, 1682 None, 1683 &HashSet::new(), ··· 1688 next: None, 1689 } 1690 ); 1691 + }); 1692 + 1693 + test_each_storage!(get_m2m_counts_reverse_order, |storage| { 1694 + // Create links from different DIDs to different targets 1695 + storage.push( 1696 + &ActionableEvent::CreateLinks { 1697 + record_id: RecordId { 1698 + did: "did:plc:user1".into(), 1699 + collection: "app.t.c".into(), 1700 + rkey: "post1".into(), 1701 + }, 1702 + links: vec![ 1703 + CollectedLink { 1704 + target: Link::Uri("a.com".into()), 1705 + path: ".abc.uri".into(), 1706 + }, 1707 + CollectedLink { 1708 + target: Link::Uri("b.com".into()), 1709 + path: ".def.uri".into(), 1710 + }, 1711 + ], 1712 + }, 1713 + 0, 1714 + )?; 1715 + storage.push( 1716 + &ActionableEvent::CreateLinks { 1717 + record_id: RecordId { 1718 + did: "did:plc:user2".into(), 1719 + collection: "app.t.c".into(), 1720 + rkey: "post1".into(), 1721 + }, 1722 + links: vec![ 1723 + CollectedLink { 1724 + target: Link::Uri("a.com".into()), 1725 + path: ".abc.uri".into(), 1726 + }, 1727 + CollectedLink { 1728 + target: Link::Uri("c.com".into()), 1729 + path: ".def.uri".into(), 1730 + }, 1731 + ], 1732 + }, 1733 + 1, 1734 + )?; 1735 + storage.push( 1736 + &ActionableEvent::CreateLinks { 1737 + record_id: RecordId { 1738 + did: "did:plc:user3".into(), 1739 + collection: "app.t.c".into(), 1740 + rkey: "post1".into(), 1741 + }, 1742 + links: vec![ 1743 + CollectedLink { 1744 + target: Link::Uri("a.com".into()), 1745 + path: ".abc.uri".into(), 1746 + }, 1747 + CollectedLink { 1748 + target: Link::Uri("d.com".into()), 1749 + path: ".def.uri".into(), 1750 + }, 1751 + ], 1752 + }, 1753 + 2, 1754 + )?; 1755 + 1756 + // Test reverse: false (default order - by target ascending) 1757 + let counts = storage.get_many_to_many_counts( 1758 + "a.com", 1759 + "app.t.c", 1760 + ".abc.uri", 1761 + ".def.uri", 1762 + false, 1763 + 10, 1764 + None, 1765 + &HashSet::new(), 1766 + &HashSet::new(), 1767 + )?; 1768 + assert_eq!(counts.items.len(), 3); 1769 + // Should be sorted by target in ascending order (alphabetical) 1770 + assert_eq!(counts.items[0].0, "b.com"); 1771 + assert_eq!(counts.items[1].0, "c.com"); 1772 + assert_eq!(counts.items[2].0, "d.com"); 1773 + 1774 + // Test reverse: true (descending order - by target descending) 1775 + let counts = storage.get_many_to_many_counts( 1776 + "a.com", 1777 + "app.t.c", 1778 + ".abc.uri", 1779 + ".def.uri", 1780 + true, 1781 + 10, 1782 + None, 1783 + &HashSet::new(), 1784 + &HashSet::new(), 1785 + )?; 1786 + assert_eq!(counts.items.len(), 3); 1787 + // Should be sorted by target in descending order (reverse alphabetical) 1788 + assert_eq!(counts.items[0].0, "d.com"); 1789 + assert_eq!(counts.items[1].0, "c.com"); 1790 + assert_eq!(counts.items[2].0, "b.com"); 1791 }); 1792 }
+34 -4
constellation/src/storage/rocks_store.rs
··· 941 collection: &str, 942 path: &str, 943 path_to_other: &str, 944 limit: u64, 945 after: Option<String>, 946 filter_dids: &HashSet<Did>, ··· 1071 } 1072 1073 let mut items: Vec<(String, u64, u64)> = Vec::with_capacity(grouped_counts.len()); 1074 for (target_id, (n, dids)) in &grouped_counts { 1075 let Some(target) = self 1076 .target_id_table ··· 1080 continue; 1081 }; 1082 items.push((target.0 .0, *n, dids.len() as u64)); 1083 } 1084 1085 let next = if grouped_counts.len() as u64 >= limit { ··· 1127 target: &str, 1128 collection: &str, 1129 path: &str, 1130 limit: u64, 1131 until: Option<u64>, 1132 filter_dids: &HashSet<Did>, ··· 1167 1168 let (alive, gone) = linkers.count(); 1169 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 1174 - let did_id_rkeys = linkers.0[begin..end].iter().rev().collect::<Vec<_>>(); 1175 1176 let mut items = Vec::with_capacity(did_id_rkeys.len()); 1177 // TODO: use get-many (or multi-get or whatever it's called)
··· 941 collection: &str, 942 path: &str, 943 path_to_other: &str, 944 + reverse: bool, 945 limit: u64, 946 after: Option<String>, 947 filter_dids: &HashSet<Did>, ··· 1072 } 1073 1074 let mut items: Vec<(String, u64, u64)> = Vec::with_capacity(grouped_counts.len()); 1075 + 1076 for (target_id, (n, dids)) in &grouped_counts { 1077 let Some(target) = self 1078 .target_id_table ··· 1082 continue; 1083 }; 1084 items.push((target.0 .0, *n, dids.len() as u64)); 1085 + } 1086 + 1087 + // Sort in desired direction 1088 + if reverse { 1089 + items.sort_by(|a, b| b.cmp(a)); // descending 1090 + } else { 1091 + items.sort(); // ascending 1092 } 1093 1094 let next = if grouped_counts.len() as u64 >= limit { ··· 1136 target: &str, 1137 collection: &str, 1138 path: &str, 1139 + reverse: bool, 1140 limit: u64, 1141 until: Option<u64>, 1142 filter_dids: &HashSet<Did>, ··· 1177 1178 let (alive, gone) = linkers.count(); 1179 let total = alive + gone; 1180 + 1181 + let end: usize; 1182 + let begin: usize; 1183 + let next: Option<u64>; 1184 1185 + if reverse { 1186 + begin = until.map(|u| (u - 1) as usize).unwrap_or(0); 1187 + end = std::cmp::min(begin + limit as usize, total as usize); 1188 + 1189 + next = if end < total as usize { 1190 + Some(end as u64 + 1) 1191 + } else { 1192 + None 1193 + } 1194 + } else { 1195 + end = until.map(|u| std::cmp::min(u, total)).unwrap_or(total) as usize; 1196 + begin = end.saturating_sub(limit as usize); 1197 + next = if begin == 0 { None } else { Some(begin as u64) }; 1198 + } 1199 + 1200 + let mut did_id_rkeys = linkers.0[begin..end].iter().rev().collect::<Vec<_>>(); 1201 + 1202 + if reverse { 1203 + did_id_rkeys.reverse(); 1204 + } 1205 1206 let mut items = Vec::with_capacity(did_id_rkeys.len()); 1207 // TODO: use get-many (or multi-get or whatever it's called)
+4
constellation/templates/base.html.j2
··· 40 padding: 0.5em 0.3em; 41 max-width: 100%; 42 } 43 .stat { 44 color: #f90; 45 font-size: 1.618rem;
··· 40 padding: 0.5em 0.3em; 41 max-width: 100%; 42 } 43 + pre.code input { 44 + margin: 0; 45 + padding: 0; 46 + } 47 .stat { 48 color: #f90; 49 font-size: 1.618rem;
+2 -1
constellation/templates/get-backlinks.html.j2
··· 6 7 {% block content %} 8 9 - {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit) %} 10 11 <h2> 12 Links to <code>{{ query.subject }}</code> ··· 40 <input type="hidden" name="did" value="{{ did }}" /> 41 {% endfor %} 42 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 43 <button type="submit">next page&hellip;</button> 44 </form> 45 {% else %}
··· 6 7 {% block content %} 8 9 + {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit, query.reverse) %} 10 11 <h2> 12 Links to <code>{{ query.subject }}</code> ··· 40 <input type="hidden" name="did" value="{{ did }}" /> 41 {% endfor %} 42 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 43 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 44 <button type="submit">next page&hellip;</button> 45 </form> 46 {% else %}
+2
constellation/templates/get-many-to-many-counts.html.j2
··· 13 query.did, 14 query.other_subject, 15 query.limit, 16 ) %} 17 18 <h2> ··· 53 {% endfor %} 54 <input type="hidden" name="limit" value="{{ query.limit }}" /> 55 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 56 <button type="submit">next page&hellip;</button> 57 </form> 58 {% else %}
··· 13 query.did, 14 query.other_subject, 15 query.limit, 16 + query.reverse, 17 ) %} 18 19 <h2> ··· 54 {% endfor %} 55 <input type="hidden" name="limit" value="{{ query.limit }}" /> 56 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 57 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 58 <button type="submit">next page&hellip;</button> 59 </form> 60 {% else %}
+7 -2
constellation/templates/hello.html.j2
··· 49 <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li> 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 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 52 </ul> 53 54 <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 57 58 <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToManyCounts</code></h3> ··· 68 <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> 69 <li><p><code>otherSubject</code>: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple users. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 70 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 71 </ul> 72 73 <p style="margin-bottom: 0"><strong>Try it:</strong></p> ··· 78 [""], 79 [""], 80 25, 81 ) %} 82 83 ··· 96 <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 <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 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 99 </ul> 100 101 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 102 - {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16) %} 103 104 105 <h3 class="route"><code>GET /links/distinct-dids</code></h3>
··· 49 <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li> 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 <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> 53 </ul> 54 55 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 56 + {% call 57 + try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16, false) %} 58 59 60 <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToManyCounts</code></h3> ··· 70 <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> 71 <li><p><code>otherSubject</code>: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple users. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 72 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 73 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 74 </ul> 75 76 <p style="margin-bottom: 0"><strong>Try it:</strong></p> ··· 81 [""], 82 [""], 83 25, 84 + false, 85 ) %} 86 87 ··· 100 <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> 101 <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> 102 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 103 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 104 </ul> 105 106 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 107 + {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16, false) %} 108 109 110 <h3 class="route"><code>GET /links/distinct-dids</code></h3>
+2 -1
constellation/templates/links.html.j2
··· 6 7 {% block content %} 8 9 - {% call try_it::links(query.target, query.collection, query.path, query.did, query.limit) %} 10 11 <h2> 12 Links to <code>{{ query.target }}</code> ··· 37 <input type="hidden" name="collection" value="{{ query.collection }}" /> 38 <input type="hidden" name="path" value="{{ query.path }}" /> 39 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 40 <button type="submit">next page&hellip;</button> 41 </form> 42 {% else %}
··· 6 7 {% block content %} 8 9 + {% call try_it::links(query.target, query.collection, query.path, query.did, query.limit, query.reverse) %} 10 11 <h2> 12 Links to <code>{{ query.target }}</code> ··· 37 <input type="hidden" name="collection" value="{{ query.collection }}" /> 38 <input type="hidden" name="path" value="{{ query.path }}" /> 39 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 40 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 41 <button type="submit">next page&hellip;</button> 42 </form> 43 {% else %}
+10 -6
constellation/templates/try-it-macros.html.j2
··· 1 - {% macro get_backlinks(subject, source, dids, limit) %} 2 <form method="get" action="/xrpc/blue.microcosm.links.getBacklinks"> 3 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getBacklinks 4 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 6 {%- for did in dids %}{% if !did.is_empty() %} 7 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 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> 10 </form> 11 <script> 12 const addDidButton = document.getElementById('add-did'); ··· 24 </script> 25 {% endmacro %} 26 27 - {% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit) %} 28 <form method="get" action="/xrpc/blue.microcosm.links.getManyToManyCounts"> 29 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getManyToManyCounts 30 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 36 {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %} 37 &otherSubject= <input type="text" name="did" value="{{ otherSubject }}" placeholder="at-uri, did, uri..." />{% endif %}{% endfor %} 38 <span id="m2m-did-placeholder"></span> <button id="m2m-add-did">+ did filter</button> 39 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 40 </form> 41 <script> 42 const m2mAddDidButton = document.getElementById('m2m-add-did'); ··· 66 </script> 67 {% endmacro %} 68 69 - {% macro links(target, collection, path, dids, limit) %} 70 <form method="get" action="/links"> 71 <pre class="code"><strong>GET</strong> /links 72 ?target= <input type="text" name="target" value="{{ target }}" placeholder="target" /> ··· 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="did-placeholder"></span> <button id="add-did">+ did filter</button> 78 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 79 </form> 80 <script> 81 const addDidButton = document.getElementById('add-did');
··· 1 + {% macro get_backlinks(subject, source, dids, limit, reverse) %} 2 <form method="get" action="/xrpc/blue.microcosm.links.getBacklinks"> 3 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getBacklinks 4 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 6 {%- for did in dids %}{% if !did.is_empty() %} 7 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 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" /> 10 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"><button type="submit">get links</button></pre> 11 </form> 12 <script> 13 const addDidButton = document.getElementById('add-did'); ··· 25 </script> 26 {% endmacro %} 27 28 + {% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit, reverse) %} 29 <form method="get" action="/xrpc/blue.microcosm.links.getManyToManyCounts"> 30 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getManyToManyCounts 31 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 37 {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %} 38 &otherSubject= <input type="text" name="did" value="{{ otherSubject }}" placeholder="at-uri, did, uri..." />{% endif %}{% endfor %} 39 <span id="m2m-did-placeholder"></span> <button id="m2m-add-did">+ did filter</button> 40 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 41 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"><button type="submit">get links</button></pre> 42 </form> 43 <script> 44 const m2mAddDidButton = document.getElementById('m2m-add-did'); ··· 68 </script> 69 {% endmacro %} 70 71 + {% macro links(target, collection, path, dids, limit, reverse) %} 72 <form method="get" action="/links"> 73 <pre class="code"><strong>GET</strong> /links 74 ?target= <input type="text" name="target" value="{{ target }}" placeholder="target" /> ··· 77 {%- for did in dids %}{% if !did.is_empty() %} 78 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 79 <span id="did-placeholder"></span> <button id="add-did">+ did filter</button> 80 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 81 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"> 82 + <button type="submit">get links</button></pre> 83 </form> 84 <script> 85 const addDidButton = document.getElementById('add-did');