diff --git a/ufos/Cargo.toml b/ufos/Cargo.toml index bbabe69..7e99ac6 100644 --- a/ufos/Cargo.toml +++ b/ufos/Cargo.toml @@ -13,7 +13,7 @@ chrono = { version = "0.4.41", features = ["serde"] } clap = { version = "4.5.31", features = ["derive"] } dropshot = "0.16.0" env_logger = "0.11.7" -fjall = { git = "https://github.com/fjall-rs/fjall.git", features = ["lz4"] } +fjall = { git = "https://github.com/fjall-rs/fjall.git", rev = "fb229572bb7d1d6966a596994dc1708e47ec57d8", features = ["lz4"] } getrandom = "0.3.3" http = "1.3.1" jetstream = { path = "../jetstream", features = ["metrics"] } -- 2.51.2 From bf636d698a6b249cbb4090f6a7d69139e128a652 Mon Sep 17 00:00:00 2001 From: mary <148872143+mary-ext@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:59:30 +0700 Subject: [PATCH 02/28] add lexicon documents --- .../blue.microcosm/links/getBacklinks.json | 95 ++++++++++++++++++ .../links/getManyToManyCounts.json | 99 +++++++++++++++++++ .../identity/resolveMiniDoc.json | 56 +++++++++++ .../com.bad-example/repo/getUriRecord.json | 54 ++++++++++ 4 files changed, 304 insertions(+) create mode 100644 lexicons/blue.microcosm/links/getBacklinks.json create mode 100644 lexicons/blue.microcosm/links/getManyToManyCounts.json create mode 100644 lexicons/com.bad-example/identity/resolveMiniDoc.json create mode 100644 lexicons/com.bad-example/repo/getUriRecord.json diff --git a/lexicons/blue.microcosm/links/getBacklinks.json b/lexicons/blue.microcosm/links/getBacklinks.json new file mode 100644 index 0000000..ec6c511 --- /dev/null +++ b/lexicons/blue.microcosm/links/getBacklinks.json @@ -0,0 +1,95 @@ +{ + "lexicon": 1, + "id": "blue.microcosm.links.getBacklinks", + "defs": { + "main": { + "type": "query", + "description": "a list of records linking to any record, identity, or uri", + "parameters": { + "type": "params", + "required": [ + "subject", + "source" + ], + "properties": { + "subject": { + "type": "string", + "format": "uri", + "description": "the target being linked to (at-uri, did, or uri)" + }, + "source": { + "type": "string", + "description": "collection and path specification (e.g., 'app.bsky.feed.like:subject.uri')" + }, + "did": { + "type": "array", + "description": "filter links to those from specific users", + "items": { + "type": "string", + "format": "did" + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 16, + "description": "number of results to return" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "total", + "records" + ], + "properties": { + "total": { + "type": "integer", + "description": "total number of matching links" + }, + "records": { + "type": "array", + "items": { + "type": "ref", + "ref": "#linkRecord" + } + }, + "cursor": { + "type": "string", + "description": "pagination cursor" + } + } + } + } + }, + "linkRecord": { + "type": "object", + "required": [ + "did", + "collection", + "rkey" + ], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "the DID of the linking record's repository" + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "the collection of the linking record" + }, + "rkey": { + "type": "string", + "format": "record-key", + "description": "the record key of the linking record" + } + } + } + } +} diff --git a/lexicons/blue.microcosm/links/getManyToManyCounts.json b/lexicons/blue.microcosm/links/getManyToManyCounts.json new file mode 100644 index 0000000..c918d3a --- /dev/null +++ b/lexicons/blue.microcosm/links/getManyToManyCounts.json @@ -0,0 +1,99 @@ +{ + "lexicon": 1, + "id": "blue.microcosm.links.getManyToManyCounts", + "defs": { + "main": { + "type": "query", + "description": "count many-to-many relationships with secondary link paths", + "parameters": { + "type": "params", + "required": [ + "subject", + "source", + "pathToOther" + ], + "properties": { + "subject": { + "type": "string", + "format": "uri", + "description": "the primary target being linked to (at-uri, did, or uri)" + }, + "source": { + "type": "string", + "description": "collection and path specification for the primary link" + }, + "pathToOther": { + "type": "string", + "description": "path to the secondary link in the many-to-many record (e.g., 'otherThing.uri')" + }, + "did": { + "type": "array", + "description": "filter links to those from specific users", + "items": { + "type": "string", + "format": "did" + } + }, + "otherSubject": { + "type": "array", + "description": "filter secondary links to specific subjects", + "items": { + "type": "string" + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 16, + "description": "number of results to return" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "counts_by_other_subject" + ], + "properties": { + "counts_by_other_subject": { + "type": "array", + "items": { + "type": "ref", + "ref": "#countBySubject" + } + }, + "cursor": { + "type": "string", + "description": "pagination cursor" + } + } + } + } + }, + "countBySubject": { + "type": "object", + "required": [ + "subject", + "total", + "distinct" + ], + "properties": { + "subject": { + "type": "string", + "description": "the secondary subject being counted" + }, + "total": { + "type": "integer", + "description": "total number of links to this subject" + }, + "distinct": { + "type": "integer", + "description": "number of distinct DIDs linking to this subject" + } + } + } + } +} diff --git a/lexicons/com.bad-example/identity/resolveMiniDoc.json b/lexicons/com.bad-example/identity/resolveMiniDoc.json new file mode 100644 index 0000000..89daf48 --- /dev/null +++ b/lexicons/com.bad-example/identity/resolveMiniDoc.json @@ -0,0 +1,56 @@ +{ + "lexicon": 1, + "id": "com.bad-example.identity.resolveMiniDoc", + "defs": { + "main": { + "type": "query", + "description": "like com.atproto.identity.resolveIdentity but instead of the full didDoc it returns an atproto-relevant subset", + "parameters": { + "type": "params", + "required": [ + "identifier" + ], + "properties": { + "identifier": { + "type": "string", + "format": "at-identifier", + "description": "handle or DID to resolve" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "did", + "handle", + "pds", + "signing_key" + ], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID, bi-directionally verified if a handle was provided in the query" + }, + "handle": { + "type": "string", + "format": "handle", + "description": "the validated handle of the account or 'handle.invalid' if the handle did not bi-directionally match the DID document" + }, + "pds": { + "type": "string", + "format": "uri", + "description": "the identity's PDS URL" + }, + "signing_key": { + "type": "string", + "description": "the atproto signing key publicKeyMultibase" + } + } + } + } + } + } +} diff --git a/lexicons/com.bad-example/repo/getUriRecord.json b/lexicons/com.bad-example/repo/getUriRecord.json new file mode 100644 index 0000000..379be72 --- /dev/null +++ b/lexicons/com.bad-example/repo/getUriRecord.json @@ -0,0 +1,54 @@ +{ + "lexicon": 1, + "id": "com.bad-example.repo.getUriRecord", + "defs": { + "main": { + "type": "query", + "description": "ergonomic complement to com.atproto.repo.getRecord which accepts an at-uri instead of individual repo/collection/rkey params", + "parameters": { + "type": "params", + "required": [ + "at_uri" + ], + "properties": { + "at_uri": { + "type": "string", + "format": "at-uri", + "description": "the at-uri of the record (identifier can be a DID or handle)" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "optional CID of the version of the record. if not specified, return the most recent version. if specified and a newer version exists, returns 404." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "uri", + "value" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "at-uri for this record" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "CID for this exact version of the record" + }, + "value": { + "type": "unknown", + "description": "the record itself" + } + } + } + } + } + } +} -- 2.51.2 From 175a71a00c228dc61a9652f34e81d02abf002f6e Mon Sep 17 00:00:00 2001 From: phil Date: Tue, 18 Nov 2025 08:00:36 -0500 Subject: [PATCH 03/28] read error: fix error tight loop when tungstenite said we should keep polling until ConnectionClosed, they probably didn't mean ignore all other errors. --- constellation/src/consumer/jetstream.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/constellation/src/consumer/jetstream.rs b/constellation/src/consumer/jetstream.rs index ea56864..3808b48 100644 --- a/constellation/src/consumer/jetstream.rs +++ b/constellation/src/consumer/jetstream.rs @@ -226,13 +226,20 @@ pub fn consume_jetstream( println!("jetstream closed the websocket cleanly."); break; } - r => eprintln!("jetstream: close result after error: {r:?}"), + Err(_) => { + counter!("jetstream_read_fail", "url" => stream.clone(), "reason" => "dirty close").increment(1); + println!("jetstream failed to close the websocket cleanly."); + break; + } + Ok(r) => { + eprintln!("jetstream: close result after error: {r:?}"); + counter!("jetstream_read_fail", "url" => stream.clone(), "reason" => "read error") + .increment(1); + // if we didn't immediately get ConnectionClosed, we should keep polling read + // until we get it. + continue; + } } - counter!("jetstream_read_fail", "url" => stream.clone(), "reason" => "read error") - .increment(1); - // if we didn't immediately get ConnectionClosed, we should keep polling read - // until we get it. - continue; } }; -- 2.51.2 From f4c3b9b45e812b1fc419ab70a3410834121ef599 Mon Sep 17 00:00:00 2001 From: mxngls Date: Wed, 17 Dec 2025 12:14:39 +0100 Subject: [PATCH 04/28] 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= +
    {% endmacro %} -{% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit, reverse) %} +{% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit) %}
    GET /xrpc/blue.microcosm.links.getManyToManyCounts
       ?subject=      
    @@ -37,8 +37,7 @@
       {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %}
       &otherSubject= {% endif %}{% endfor %}
                      
    -  &limit=        
    -  &reverse=      
    + &limit=
    {% endmacro %} -{% macro links(target, collection, path, dids, limit, reverse) %} +{% macro links(target, collection, path, dids, limit) %}
    GET /links
       ?target=     
    @@ -77,9 +76,7 @@
       {%- for did in dids %}{% if !did.is_empty() %}
       &did=        {% endif %}{% endfor %}
                    
    -  &limit=      
    -  &reverse=	   
    -  
    + &limit=