A resource can show only its newest few
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m0s

`limit: n` on a resource spec, applied after `jq`.

The releases card is what asked for it: a page that grows by one card
per release gets longer forever, and the cut a visitor should run is the
newest one — the rest are history, not an invitation. redoal's card had
every release on it.

After `jq` rather than before, for two reasons: it caps what the page
will actually render whatever the filter did, and content that reorders
with the filter can then take the top of its own ordering rather than
the top of the source's. A source yielding a single record has no length
and passes through untouched — turning one record into an empty list
would be worse than ignoring a limit that cannot apply. `limit: 0` on a
list does mean nothing, and the `empty` line speaks instead, which is a
coherent thing for content to ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByshXuDEzH8hXh5qp4VgGk
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-04 22:51:12 +02:00
co-authored by Claude Opus 5
parent 48ae5e07c2
commit 22d086f9e1
3 changed files with 89 additions and 3 deletions
+9
View File
@@ -255,6 +255,7 @@ reads is never a client-supplied parameter.
states: [approved, refused] # only these; empty means all states: [approved, refused] # only these; empty means all
where: { field: case, equals: "{key}" } where: { field: case, equals: "{key}" }
jq: ".[] | {name, url: .html_url}" jq: ".[] | {name, url: .html_url}"
limit: 1 # at most this many, after jq
empty: Nothing decided yet. empty: Nothing decided yet.
link: { to: "/notices/{id}", label: Answer this } # a page per record link: { to: "/notices/{id}", label: Answer this } # a page per record
transitions: # the desk's buttons transitions: # the desk's buttons
@@ -264,6 +265,14 @@ reads is never a client-supplied parameter.
`source.kind` is one of `kv`, `gitea_starred`, `source.kind` is one of `kv`, `gitea_starred`,
`gitea_releases`, `url`. `gitea_releases`, `url`.
`limit: 1` shows only the first item — for a feed where only the newest
matters. A releases card wants the cut someone should run, not every
cut there has ever been, and a page that grows by one card per release
gets longer forever. It applies *after* `jq`, so a filter can reorder
and the limit takes the top of that ordering. A source that yields a
single record has no length and ignores it; `limit: 0` on a list shows
nothing, and the `empty` line speaks instead.
A spec with neither `public: true` nor `requires_group` is A spec with neither `public: true` nor `requires_group` is
unreachable: reads fail closed, and mutations always need a group unreachable: reads fail closed, and mutations always need a group
whatever `public` says. whatever `public` says.
+12
View File
@@ -519,6 +519,18 @@ pub struct ResourceSpec {
/// it where the silence needs a voice ("Nothing released yet"). /// it where the silence needs a voice ("Nothing released yet").
#[serde(default)] #[serde(default)]
pub empty: Option<String>, pub empty: Option<String>,
/// Show at most this many items, applied after `jq`. For a feed
/// where only the newest matters: a releases card wants the cut you
/// should run, not every cut there has ever been, and a page that
/// grows by one card per release gets longer forever.
///
/// After `jq` rather than before, so it caps what the page will
/// actually render whatever the filter did — and so content can
/// reorder with jq and still take the top of its own ordering. A
/// source that yields a single record has no length and passes
/// through untouched. `None` shows everything.
#[serde(default)]
pub limit: Option<usize>,
} }
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket /// Where a resource's live data comes from. `Kv` (a NATS KV bucket
+68 -3
View File
@@ -347,9 +347,29 @@ async fn fetch_resource_value(
ResourceSource::Url { url } => fetch_url_json(url, params).await?, ResourceSource::Url { url } => fetch_url_json(url, params).await?,
}; };
match &resource.jq { let shaped = match &resource.jq {
Some(filter) => apply_jq(filter, &value), Some(filter) => apply_jq(filter, &value)?,
None => Ok(value), None => value,
};
Ok(apply_limit(resource.limit, shaped))
}
/// Keep at most `n` items, after `jq` has had its say.
///
/// Anything that is not a list passes through: a single record has no
/// length to cap, and silently turning one into an empty list would be
/// worse than ignoring a limit that cannot apply. A limit of zero does
/// mean nothing — the resource's `empty` line speaks instead, which is
/// a coherent thing for content to ask for.
#[cfg(feature = "ssr")]
fn apply_limit(limit: Option<usize>, value: serde_json::Value) -> serde_json::Value {
let Some(n) = limit else { return value };
match value {
serde_json::Value::Array(mut items) => {
items.truncate(n);
serde_json::Value::Array(items)
}
other => other,
} }
} }
@@ -664,6 +684,51 @@ mod tests {
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), &params)); assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), &params));
} }
/// `limit` caps what the page renders, and does it after `jq` so
/// content can order with the filter and take the top of its own
/// ordering. The releases card is the case: the newest cut is the
/// one you should run, and a page that grows a card per release
/// gets longer forever.
#[test]
fn limit_caps_a_list_after_the_filter() {
let releases = serde_json::json!([
{"name": "redoal 0.1.17", "tag_name": "v0.1.17", "body": "newest"},
{"name": "redoal 0.1.16", "tag_name": "v0.1.16", "body": "older"},
{"name": "redoal 0.1.15", "tag_name": "v0.1.15", "body": "oldest"}
]);
// The filter redoal/questions actually declares: bare objects,
// never a wrapped array — the frontend re-wraps.
let filter = ".[] | {name: .name, description: .body, tag: .tag_name, url: null}";
let shaped = apply_jq(filter, &releases).expect("filter runs");
let all = apply_limit(None, shaped.clone());
assert_eq!(all.as_array().map(Vec::len), Some(3), "no limit shows everything");
let latest = apply_limit(Some(1), shaped.clone());
let rows = latest.as_array().expect("still a list");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["tag"], "v0.1.17", "and it is the newest, which Gitea returns first");
let two = apply_limit(Some(2), shaped.clone());
assert_eq!(two.as_array().map(Vec::len), Some(2));
// Asking for more than there is is not an error.
let plenty = apply_limit(Some(99), shaped);
assert_eq!(plenty.as_array().map(Vec::len), Some(3));
}
/// A limit that cannot apply is ignored rather than guessed at.
#[test]
fn limit_leaves_a_single_record_alone() {
let one = serde_json::json!({"name": "a record", "state": "open"});
assert_eq!(apply_limit(Some(1), one.clone()), one);
assert_eq!(apply_limit(Some(0), one.clone()), one, "no length to cap");
// Zero on a list does mean nothing — the `empty` line speaks.
let list = serde_json::json!([{"a": 1}, {"a": 2}]);
assert_eq!(apply_limit(Some(0), list).as_array().map(Vec::len), Some(0));
}
/// A canned, Gitea-API-shaped fixture - the same fields the real /// A canned, Gitea-API-shaped fixture - the same fields the real
/// `/users/{username}/starred` endpoint returns (Gitea 1.27: /// `/users/{username}/starred` endpoint returns (Gitea 1.27:
/// `stars_count`, not GitHub's `stargazers_count`; `website` is "" /// `stars_count`, not GitHub's `stargazers_count`; `website` is ""