From f4c3b9b45e812b1fc419ab70a3410834121ef599 Mon Sep 17 00:00:00 2001 From: mxngls Date: Wed, 17 Dec 2025 12:14:39 +0100 Subject: [PATCH] Add reverse ordering support to link query endpoints - Add "reverse" boolean parameter to three endpoints: 1. getManyToManyCounts (grouped counts) 2. getBacklinks (links to subject) 3. links (target links) - Implement reverse iteration logic in both storage backends (mem_store and rocks_store) for __reverse-chronological__ ordering - Update HTML templates with (reverse) checkboxes --- constellation/src/server/mod.rs | 15 ++++++++-- constellation/src/storage/mem_store.rs | 28 +++++++++++++++---- constellation/src/storage/mod.rs | 2 ++ constellation/src/storage/rocks_store.rs | 16 ++++++++++- constellation/templates/base.html.j2 | 4 +++ constellation/templates/get-backlinks.html.j2 | 3 +- .../templates/get-many-to-many-counts.html.j2 | 2 ++ constellation/templates/hello.html.j2 | 9 ++++-- constellation/templates/links.html.j2 | 3 +- constellation/templates/try-it-macros.html.j2 | 16 +++++++---- 10 files changed, 78 insertions(+), 20 deletions(-) diff --git a/constellation/src/server/mod.rs b/constellation/src/server/mod.rs index 9c3d1ad..ee125bc 100644 --- a/constellation/src/server/mod.rs +++ b/constellation/src/server/mod.rs @@ -239,6 +239,9 @@ struct GetManyToManyCountsQuery { /// Set the max number of links to return per page of results #[serde(default = "get_default_cursor_limit")] limit: u64, + /// Allow returning links in reverse order (default: false) + #[serde(default)] + reverse: bool, } #[derive(Serialize)] struct OtherSubjectCount { @@ -301,6 +304,7 @@ fn get_many_to_many_counts( collection, &path, &path_to_other, + query.reverse, limit, cursor_key, &filter_dids, @@ -409,7 +413,9 @@ struct GetBacklinksQuery { /// Set the max number of links to return per page of results #[serde(default = "get_default_cursor_limit")] limit: u64, - // TODO: allow reverse (er, forward) order as well + /// Allow returning links in reverse order (default: false) + #[serde(default)] + reverse: bool, } #[derive(Template, Serialize)] #[template(path = "get-backlinks.html.j2")] @@ -460,6 +466,7 @@ fn get_backlinks( &query.subject, collection, &path, + query.reverse, limit, until, &filter_dids, @@ -508,7 +515,9 @@ struct GetLinkItemsQuery { from_dids: Option, // comma separated: gross #[serde(default = "get_default_cursor_limit")] limit: u64, - // TODO: allow reverse (er, forward) order as well + /// Allow returning links in reverse order (default: false) + #[serde(default)] + reverse: bool, } #[derive(Template, Serialize)] #[template(path = "links.html.j2")] @@ -562,6 +571,7 @@ fn get_links( &query.target, &query.collection, &query.path, + query.reverse, limit, until, &filter_dids, @@ -594,7 +604,6 @@ struct GetDidItemsQuery { path: String, cursor: Option, limit: Option, - // TODO: allow reverse (er, forward) order as well } #[derive(Template, Serialize)] #[template(path = "dids.html.j2")] diff --git a/constellation/src/storage/mem_store.rs b/constellation/src/storage/mem_store.rs index abc84a2..c08dc37 100644 --- a/constellation/src/storage/mem_store.rs +++ b/constellation/src/storage/mem_store.rs @@ -140,6 +140,7 @@ impl LinkReader for MemStorage { collection: &str, path: &str, path_to_other: &str, + reverse: bool, limit: u64, after: Option, filter_dids: &HashSet, @@ -157,8 +158,10 @@ impl LinkReader for MemStorage { let filter_to_targets: HashSet = HashSet::from_iter(filter_to_targets.iter().map(|s| Target::new(s))); - let mut grouped_counts: HashMap)> = HashMap::new(); - for (did, rkey) in linkers.iter().flatten().cloned() { + // the last type field here acts as an index to allow keeping track of the order in which + // we encountred single elements + let mut grouped_counts: HashMap, usize)> = HashMap::new(); + for (idx, (did, rkey)) in linkers.iter().flatten().cloned().enumerate() { if !filter_dids.is_empty() && !filter_dids.contains(&did) { continue; } @@ -184,16 +187,24 @@ impl LinkReader for MemStorage { .take(1) .next() { - let e = grouped_counts.entry(fwd_target.clone()).or_default(); + let e = + grouped_counts + .entry(fwd_target.clone()) + .or_insert((0, HashSet::new(), idx)); e.0 += 1; e.1.insert(did.clone()); } } let mut items: Vec<(String, u64, u64)> = grouped_counts .iter() - .map(|(k, (n, u))| (k.0.clone(), *n, u.len() as u64)) + .map(|(k, (n, u, _))| (k.0.clone(), *n, u.len() as u64)) .collect(); - items.sort(); + // sort in reverse order to show entries from oldest to newest + if reverse { + items.sort_by(|a, b| b.cmp(a)); + } else { + items.sort(); + } items = items .into_iter() .skip_while(|(t, _, _)| after.as_ref().map(|a| t <= a).unwrap_or(false)) @@ -239,6 +250,7 @@ impl LinkReader for MemStorage { target: &str, collection: &str, path: &str, + reverse: bool, limit: u64, until: Option, filter_dids: &HashSet, @@ -261,7 +273,7 @@ impl LinkReader for MemStorage { }); }; - let did_rkeys: Vec<_> = if !filter_dids.is_empty() { + let mut did_rkeys: Vec<_> = if !filter_dids.is_empty() { did_rkeys .iter() .filter(|m| { @@ -285,6 +297,10 @@ impl LinkReader for MemStorage { let alive = did_rkeys.iter().flatten().count(); let gone = total - alive; + if reverse { + did_rkeys.reverse(); + } + let items: Vec<_> = did_rkeys[begin..end] .iter() .rev() diff --git a/constellation/src/storage/mod.rs b/constellation/src/storage/mod.rs index 68010ca..b31ed13 100644 --- a/constellation/src/storage/mod.rs +++ b/constellation/src/storage/mod.rs @@ -72,6 +72,7 @@ pub trait LinkReader: Clone + Send + Sync + 'static { collection: &str, path: &str, path_to_other: &str, + reverse: bool, limit: u64, after: Option, filter_dids: &HashSet, @@ -87,6 +88,7 @@ pub trait LinkReader: Clone + Send + Sync + 'static { target: &str, collection: &str, path: &str, + reverse: bool, limit: u64, until: Option, filter_dids: &HashSet, diff --git a/constellation/src/storage/rocks_store.rs b/constellation/src/storage/rocks_store.rs index 3c7f546..419a3d6 100644 --- a/constellation/src/storage/rocks_store.rs +++ b/constellation/src/storage/rocks_store.rs @@ -941,6 +941,7 @@ impl LinkReader for RocksStorage { collection: &str, path: &str, path_to_other: &str, + reverse: bool, limit: u64, after: Option, filter_dids: &HashSet, @@ -1071,6 +1072,7 @@ impl LinkReader for RocksStorage { } let mut items: Vec<(String, u64, u64)> = Vec::with_capacity(grouped_counts.len()); + for (target_id, (n, dids)) in &grouped_counts { let Some(target) = self .target_id_table @@ -1082,6 +1084,13 @@ impl LinkReader for RocksStorage { items.push((target.0 .0, *n, dids.len() as u64)); } + // Sort in desired direction + if reverse { + items.sort_by(|a, b| b.cmp(a)); // descending + } else { + items.sort(); // ascending + } + let next = if grouped_counts.len() as u64 >= limit { // yeah.... it's a number saved as a string......sorry grouped_counts @@ -1127,6 +1136,7 @@ impl LinkReader for RocksStorage { target: &str, collection: &str, path: &str, + reverse: bool, limit: u64, until: Option, filter_dids: &HashSet, @@ -1171,7 +1181,11 @@ impl LinkReader for RocksStorage { let begin = end.saturating_sub(limit as usize); let next = if begin == 0 { None } else { Some(begin as u64) }; - let did_id_rkeys = linkers.0[begin..end].iter().rev().collect::>(); + let mut did_id_rkeys = linkers.0[begin..end].iter().rev().collect::>(); + + if reverse { + did_id_rkeys.reverse(); + } let mut items = Vec::with_capacity(did_id_rkeys.len()); // TODO: use get-many (or multi-get or whatever it's called) diff --git a/constellation/templates/base.html.j2 b/constellation/templates/base.html.j2 index 0a06610..3258d54 100644 --- a/constellation/templates/base.html.j2 +++ b/constellation/templates/base.html.j2 @@ -40,6 +40,10 @@ padding: 0.5em 0.3em; max-width: 100%; } + pre.code input { + margin: 0; + padding: 0; + } .stat { color: #f90; font-size: 1.618rem; diff --git a/constellation/templates/get-backlinks.html.j2 b/constellation/templates/get-backlinks.html.j2 index 432fb4b..fec10e8 100644 --- a/constellation/templates/get-backlinks.html.j2 +++ b/constellation/templates/get-backlinks.html.j2 @@ -6,7 +6,7 @@ {% block content %} - {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit) %} + {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit, query.reverse) %}

Links to {{ query.subject }} @@ -40,6 +40,7 @@ {% endfor %} + {% else %} diff --git a/constellation/templates/get-many-to-many-counts.html.j2 b/constellation/templates/get-many-to-many-counts.html.j2 index b27815c..849f6e1 100644 --- a/constellation/templates/get-many-to-many-counts.html.j2 +++ b/constellation/templates/get-many-to-many-counts.html.j2 @@ -13,6 +13,7 @@ query.did, query.other_subject, query.limit, + query.reverse, ) %}

@@ -53,6 +54,7 @@ {% endfor %} + {% else %} diff --git a/constellation/templates/hello.html.j2 b/constellation/templates/hello.html.j2 index b916d1c..99043ba 100644 --- a/constellation/templates/hello.html.j2 +++ b/constellation/templates/hello.html.j2 @@ -49,10 +49,12 @@
  • source: required. Example: app.bsky.feed.like:subject.uri

  • did: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze

  • limit: optional. Default: 16. Maximum: 100

  • +
  • reverse: optional, return links in reverse order. Default: false

  • Try it:

    - {% call try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16) %} + {% call + try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16, false) %}

    GET /xrpc/blue.microcosm.links.getManyToManyCounts

    @@ -68,6 +70,7 @@
  • did: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze

  • otherSubject: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple users. Example: at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r

  • limit: optional. Default: 16. Maximum: 100

  • +
  • reverse: optional, return links in reverse order. Default: false

  • Try it:

    @@ -78,6 +81,7 @@ [""], [""], 25, + false, ) %} @@ -96,10 +100,11 @@
  • did: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze

  • from_dids [deprecated]: optional. Use did instead. Example: from_dids=did:plc:vc7f4oafdgxsihk4cry2xpze,did:plc:vc7f4oafdgxsihk4cry2xpze

  • limit: optional. Default: 16. Maximum: 100

  • +
  • reverse: optional, return links in reverse order. Default: false

  • Try it:

    - {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16) %} + {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16, false) %}

    GET /links/distinct-dids

    diff --git a/constellation/templates/links.html.j2 b/constellation/templates/links.html.j2 index 24577db..ba96905 100644 --- a/constellation/templates/links.html.j2 +++ b/constellation/templates/links.html.j2 @@ -6,7 +6,7 @@ {% block content %} - {% call try_it::links(query.target, query.collection, query.path, query.did, query.limit) %} + {% call try_it::links(query.target, query.collection, query.path, query.did, query.limit, query.reverse) %}

    Links to {{ query.target }} @@ -37,6 +37,7 @@ + {% else %} diff --git a/constellation/templates/try-it-macros.html.j2 b/constellation/templates/try-it-macros.html.j2 index 6e765b6..a38831e 100644 --- a/constellation/templates/try-it-macros.html.j2 +++ b/constellation/templates/try-it-macros.html.j2 @@ -1,4 +1,4 @@ -{% macro get_backlinks(subject, source, dids, limit) %} +{% macro get_backlinks(subject, source, dids, limit, reverse) %}
    GET /xrpc/blue.microcosm.links.getBacklinks
       ?subject=    
    @@ -6,7 +6,8 @@
       {%- for did in dids %}{% if !did.is_empty() %}
       &did=        {% endif %}{% endfor %}
                    
    -  &limit=       
    + &limit= + &reverse=
    {% endmacro %} -{% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit) %} +{% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit, reverse) %}
    GET /xrpc/blue.microcosm.links.getManyToManyCounts
       ?subject=      
    @@ -36,7 +37,8 @@
       {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %}
       &otherSubject= {% endif %}{% endfor %}
                      
    -  &limit=         
    + &limit= + &reverse=
    {% endmacro %} -{% macro links(target, collection, path, dids, limit) %} +{% macro links(target, collection, path, dids, limit, reverse) %}
    GET /links
       ?target=     
    @@ -75,7 +77,9 @@
       {%- for did in dids %}{% if !did.is_empty() %}
       &did=        {% endif %}{% endfor %}
                    
    -  &limit=       
    + &limit= + &reverse= +