A resource can show only its newest few
`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:
co-authored by
Claude Opus 5
parent
48ae5e07c2
commit
22d086f9e1
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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" })), ¶ms));
|
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user