Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/hypercerts-api/references/endpoint-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Choose `listCollectionItems` when the caller needs the referenced item versions,
| Browse/filter badge definitions | `app.certified.badge.listBadgeDefinitions` | Use publisher/type filters and pagination. |
| Search badge definitions | `app.certified.badge.searchBadgeDefinitions` | `search` is required; use for text discovery, not status checking. |
| Load one award and its response status | `app.certified.badge.getBadgeAward` | Exact award AT-URI in `uri`; includes `responseStatus` (`accepted`, `rejected`, or `unanswered`) and nullable recipient response. |
| Browse/filter awards | `app.certified.badge.listBadgeAwards` | Use when the award URI is not known or multiple awards are needed; the result includes response status. |
| Browse/filter awards | `app.certified.badge.listBadgeAwards` | Use when the award URI is not known or multiple awards are needed; the result includes response status. Set `includeTotal=true` to get `totalCount`: award records matching all filters before cursor and limit, not deduplicated, also on empty pages (use `limit=1` for a count-only request). |
| Load one raw response record | `app.certified.badge.getBadgeResponse` | Exact response AT-URI in `uri`. |
| List raw responses for an award | `app.certified.badge.listBadgeResponses` | Optional `badgeAward` filter; use to inspect response records, not as a substitute for the award's derived status. |

Expand Down
12 changes: 12 additions & 0 deletions .agents/skills/hypercerts-api/references/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,13 @@
"required": false,
"description": "Opaque cursor from the previous page.",
"schema": { "type": "string" }
},
{
"name": "includeTotal",
"in": "query",
"required": false,
"description": "When true, the output includes totalCount.",
"schema": { "type": "boolean", "default": false }
}
],
"responses": {
Expand Down Expand Up @@ -4471,6 +4478,11 @@
"cursor": {
"type": "string",
"description": "Opaque cursor for the next page; omitted when pagination is complete."
},
"totalCount": {
"type": "integer",
"minimum": 0,
"description": "Number of badge award records matching all filters, before cursor and limit are applied. Not deduplicated. Only returned when includeTotal is true."
}
}
},
Expand Down
6 changes: 6 additions & 0 deletions .changeset/badge-award-total-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hypercerts-org/happyview-modules': minor
'@hypercerts-org/api': minor
---

- `app.certified.badge.listBadgeAwards` accepts `includeTotal`. When it is `true`, the output includes `totalCount`: the number of award records matching all filters, before cursor and limit, not deduplicated. It is also returned on empty pages.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or
- Work-scope-tag exact-URI retrieval, repeated-author filtering with an empty-result case, hydrated and null publisher sidecars, middle-position `indexedAt` fallback, tied pagination in both directions, and named error responses.
- Context-measurement exact retrieval and publisher-sidecar hydration, repeated author/subject filters and negative results, timestamp/URI tie pagination in both directions, missing sidecars, and named runtime errors.
- Acknowledgement exact retrieval and full-record preservation, hydrated and absent publisher sidecars, repeated author/subject filters, ascending and descending pagination across timestamp ties, and named errors.
- Badge-query baseline-aware definition feeds and discriminating filters, exact-version award/response lookups, recipient status, raw response history, bidirectional tied pagination, nullable sidecars, and named runtime errors.
- Badge-query baseline-aware definition feeds and discriminating filters, exact-version award/response lookups, recipient status, opt-in award totals across filters and past-the-end pages, raw response history, bidirectional tied pagination, nullable sidecars, and named runtime errors.
- Profile and organization queries across all four endpoints for each record type, including batch null results, profile-sidecar hydration, filters, `createdAt`/URI pagination ties, and named errors.
- Vocabulary-tag exact-URI retrieval, repeated-author filtering and no-match behavior, hydrated and nullable sidecars, bidirectional pagination ties, unsupported-search rejection, and named errors.
- Context attachment and evaluation retrieval with publisher/evaluator sidecar hydration, list filters, pagination across tied `createdAt` values, and named runtime errors. Batched comment counts cover per-record counting, CID-independent subject matching, excluded non-comment attachments, and agreement with `listAttachments`.
Expand Down
15 changes: 15 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,16 @@
"schema": {
"type": "string"
}
},
{
"name": "includeTotal",
"in": "query",
"required": false,
"description": "When true, the output includes totalCount.",
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": {
Expand Down Expand Up @@ -5822,6 +5832,11 @@
"cursor": {
"type": "string",
"description": "Opaque cursor for the next page; omitted when pagination is complete."
},
"totalCount": {
"type": "integer",
"description": "Number of badge award records matching all filters, before cursor and limit are applied. Not deduplicated. Only returned when includeTotal is true.",
"minimum": 0
}
}
},
Expand Down
10 changes: 10 additions & 0 deletions docs/sources/lexicons/app.certified.badge.listBadgeAwards.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@
"cursor": {
"type": "string",
"description": "Opaque cursor from the previous page."
},
"includeTotal": {
"type": "boolean",
"default": false,
"description": "When true, the output includes totalCount."
}
}
},
Expand Down Expand Up @@ -112,6 +117,11 @@
"cursor": {
"type": "string",
"description": "Opaque cursor for the next page; omitted when pagination is complete."
},
"totalCount": {
"type": "integer",
"minimum": 0,
"description": "Number of badge award records matching all filters, before cursor and limit are applied. Not deduplicated. Only returned when includeTotal is true."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
"cursor": {
"type": "string",
"description": "Opaque cursor from the previous page."
},
"includeTotal": {
"type": "boolean",
"default": false,
"description": "When true, the output includes totalCount."
}
}
},
Expand All @@ -77,6 +82,11 @@
"cursor": {
"type": "string",
"description": "Opaque cursor for the next page; omitted when pagination is complete."
},
"totalCount": {
"type": "integer",
"minimum": 0,
"description": "Number of badge award records matching all filters, before cursor and limit are applied. Not deduplicated. Only returned when includeTotal is true."
}
}
}
Expand Down
73 changes: 61 additions & 12 deletions happyview-modules/lua/endpoints/listBadgeAwards.lua
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,18 @@ local function badge_award_view(row)
return view
end

local function list_badge_awards(authors, badge_uris, badge_types, subjects, responses, limit, cursor, direction)
local function parse_include_total()
local value = params.includeTotal
if value == nil then return false end
if type(value) == "boolean" then return value end
value = scalar(params, "includeTotal")
if value == "true" then return true end
if value == "false" then return false end
invalid("includeTotal must be true or false")
end

local function list_badge_awards(authors, badge_uris, badge_types, subjects, responses, limit, cursor, direction,
include_total)
local where, values = { "award.collection = $1" }, { AWARD }
add_filter(where, values, "award.did", authors)
if badge_uris then
Expand All @@ -346,18 +357,23 @@ local function list_badge_awards(authors, badge_uris, badge_types, subjects, res
table.concat(placeholders, ",") .. ")"
end
end
-- The cursor is kept out of `where` so that, when a total is requested, the
-- count and the page share one `matched` set and differ only by cursor/limit.
local cursor_timestamp, cursor_uri
if cursor then
values[#values + 1] = cursor.t
local timestamp = "$" .. #values
cursor_timestamp = "($" .. #values .. ")::timestamptz"
values[#values + 1] = cursor.u
local uri = "$" .. #values
local operator = direction == "asc" and ">" or "<"
where[#where + 1] = "(sorted.sort_at, award.uri) " .. operator ..
" ((" .. timestamp .. ")::timestamptz, " .. uri .. ")"
cursor_uri = "$" .. #values
end
local operator = direction == "asc" and ">" or "<"
values[#values + 1] = limit + 1
local ordering = direction == "asc" and "ASC" or "DESC"
local sql = "SELECT award.uri, award.did, award.cid, award.indexed_at::text AS indexed_at, " ..
local page_columns = "page.uri, page.did, page.cid, page.indexed_at, page.record, page.sort_timestamp, " ..
"page.badge_uri, page.badge_cid, page.badge_indexed_at, page.badge_did, page.badge_record, " ..
"page.recipient_response_uri, page.recipient_response_cid, page.recipient_response_indexed_at, " ..
"page.recipient_response_did, page.recipient_response_record"
local select_from = "SELECT award.uri, award.did, award.cid, award.indexed_at::text AS indexed_at, " ..
"award.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " ..
"'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp, " ..
"badge.uri AS badge_uri, badge.cid AS badge_cid, badge.indexed_at::text AS badge_indexed_at, " ..
Expand All @@ -376,9 +392,39 @@ local function list_badge_awards(authors, badge_uris, badge_types, subjects, res
"AND response.record::jsonb->'badgeAward'->>'cid' = award.cid " ..
"AND response.record::jsonb->>'response' IN ('accepted', 'rejected') " ..
"ORDER BY response.indexed_at DESC NULLS LAST, response.uri DESC LIMIT 1) AS recipient_response ON TRUE " ..
"WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering ..
", award.uri " .. ordering .. " LIMIT $" .. #values
"WHERE " .. table.concat(where, " AND ")
local sql
if include_total then
local page_filter = ""
if cursor then
page_filter = " WHERE (sort_at, uri) " .. operator .. " (" .. cursor_timestamp .. ", " .. cursor_uri .. ")"
end
-- sort_at stays inside the CTEs: HappyView cannot decode raw timestamptz output columns.
local matched = select_from:gsub("^SELECT ", "SELECT sorted.sort_at, ", 1)
sql = "WITH matched AS (" .. matched .. "), " ..
"total AS (SELECT COUNT(*) AS total_count FROM matched), " ..
"page AS (SELECT * FROM matched" .. page_filter .. " ORDER BY sort_at " .. ordering ..
", uri " .. ordering .. " LIMIT $" .. #values .. ") " ..
"SELECT " .. page_columns .. ", total.total_count FROM total LEFT JOIN page ON TRUE " ..
"ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering
else
local cursor_filter = ""
if cursor then
cursor_filter = " AND (sorted.sort_at, award.uri) " .. operator .. " (" .. cursor_timestamp .. ", " .. cursor_uri .. ")"
end
sql = select_from .. cursor_filter .. " ORDER BY sorted.sort_at " .. ordering ..
", award.uri " .. ordering .. " LIMIT $" .. #values
end
local rows = query(sql, values)
local total_count
if include_total then
total_count = rows[1] and tonumber(rows[1].total_count)
if not total_count or total_count < 0 or total_count % 1 ~= 0 then
error("BadgeAwardQueryFailed: badge award count unavailable", 0)
end
-- `total LEFT JOIN page` yields one row with a NULL page when the page is empty.
if rows[1].uri == nil then rows = {} end
end
local more = #rows > limit
if more then rows[#rows] = nil end
local views, author_views = {}, {}
Expand All @@ -401,13 +447,13 @@ local function list_badge_awards(authors, badge_uris, badge_types, subjects, res
local last = rows[#rows]
next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri })
end
return views, next_cursor
return views, next_cursor, total_count
end

local function handle_list_badge_awards()
keys_only(params, {
authors = true, badgeUris = true, badgeTypes = true, subjects = true, responses = true,
sortDirection = true, limit = true, cursor = true,
sortDirection = true, limit = true, cursor = true, includeTotal = true,
})
local authors = array_values("authors", valid_did, "valid DIDs")
local badge_uris = array_values("badgeUris", valid_badge_definition_uri, "badge-definition AT-URIs")
Expand All @@ -418,9 +464,12 @@ local function handle_list_badge_awards()
local limit = parse_list_limit(params)
local direction = parse_sort_direction(params)
local cursor = decode_badge_award_cursor(scalar(params, "cursor"), direction)
local views, next_cursor = list_badge_awards(authors, badge_uris, badge_types, subjects, responses, limit, cursor, direction)
local include_total = parse_include_total()
local views, next_cursor, total_count = list_badge_awards(authors, badge_uris, badge_types, subjects, responses,
limit, cursor, direction, include_total)
local response = { badgeAwards = toarray(views) }
if next_cursor then response.cursor = next_cursor end
if include_total then response.totalCount = total_count end
return response
end

Expand Down
73 changes: 61 additions & 12 deletions happyview-modules/lua/src/listBadgeAwards.lua
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,18 @@ local function badge_award_view(row)
return view
end

local function list_badge_awards(authors, badge_uris, badge_types, subjects, responses, limit, cursor, direction)
local function parse_include_total()
local value = params.includeTotal
if value == nil then return false end
if type(value) == "boolean" then return value end
value = scalar(params, "includeTotal")
if value == "true" then return true end
if value == "false" then return false end
invalid("includeTotal must be true or false")
end

local function list_badge_awards(authors, badge_uris, badge_types, subjects, responses, limit, cursor, direction,
include_total)
local where, values = { "award.collection = $1" }, { AWARD }
add_filter(where, values, "award.did", authors)
if badge_uris then
Expand All @@ -204,18 +215,23 @@ local function list_badge_awards(authors, badge_uris, badge_types, subjects, res
table.concat(placeholders, ",") .. ")"
end
end
-- The cursor is kept out of `where` so that, when a total is requested, the
-- count and the page share one `matched` set and differ only by cursor/limit.
local cursor_timestamp, cursor_uri
if cursor then
values[#values + 1] = cursor.t
local timestamp = "$" .. #values
cursor_timestamp = "($" .. #values .. ")::timestamptz"
values[#values + 1] = cursor.u
local uri = "$" .. #values
local operator = direction == "asc" and ">" or "<"
where[#where + 1] = "(sorted.sort_at, award.uri) " .. operator ..
" ((" .. timestamp .. ")::timestamptz, " .. uri .. ")"
cursor_uri = "$" .. #values
end
local operator = direction == "asc" and ">" or "<"
values[#values + 1] = limit + 1
local ordering = direction == "asc" and "ASC" or "DESC"
local sql = "SELECT award.uri, award.did, award.cid, award.indexed_at::text AS indexed_at, " ..
local page_columns = "page.uri, page.did, page.cid, page.indexed_at, page.record, page.sort_timestamp, " ..
"page.badge_uri, page.badge_cid, page.badge_indexed_at, page.badge_did, page.badge_record, " ..
"page.recipient_response_uri, page.recipient_response_cid, page.recipient_response_indexed_at, " ..
"page.recipient_response_did, page.recipient_response_record"
local select_from = "SELECT award.uri, award.did, award.cid, award.indexed_at::text AS indexed_at, " ..
"award.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " ..
"'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp, " ..
"badge.uri AS badge_uri, badge.cid AS badge_cid, badge.indexed_at::text AS badge_indexed_at, " ..
Expand All @@ -234,9 +250,39 @@ local function list_badge_awards(authors, badge_uris, badge_types, subjects, res
"AND response.record::jsonb->'badgeAward'->>'cid' = award.cid " ..
"AND response.record::jsonb->>'response' IN ('accepted', 'rejected') " ..
"ORDER BY response.indexed_at DESC NULLS LAST, response.uri DESC LIMIT 1) AS recipient_response ON TRUE " ..
"WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering ..
", award.uri " .. ordering .. " LIMIT $" .. #values
"WHERE " .. table.concat(where, " AND ")
local sql
if include_total then
local page_filter = ""
if cursor then
page_filter = " WHERE (sort_at, uri) " .. operator .. " (" .. cursor_timestamp .. ", " .. cursor_uri .. ")"
end
-- sort_at stays inside the CTEs: HappyView cannot decode raw timestamptz output columns.
local matched = select_from:gsub("^SELECT ", "SELECT sorted.sort_at, ", 1)
sql = "WITH matched AS (" .. matched .. "), " ..
"total AS (SELECT COUNT(*) AS total_count FROM matched), " ..
"page AS (SELECT * FROM matched" .. page_filter .. " ORDER BY sort_at " .. ordering ..
", uri " .. ordering .. " LIMIT $" .. #values .. ") " ..
"SELECT " .. page_columns .. ", total.total_count FROM total LEFT JOIN page ON TRUE " ..
"ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering
else
local cursor_filter = ""
if cursor then
cursor_filter = " AND (sorted.sort_at, award.uri) " .. operator .. " (" .. cursor_timestamp .. ", " .. cursor_uri .. ")"
end
sql = select_from .. cursor_filter .. " ORDER BY sorted.sort_at " .. ordering ..
", award.uri " .. ordering .. " LIMIT $" .. #values
end
local rows = query(sql, values)
local total_count
if include_total then
total_count = rows[1] and tonumber(rows[1].total_count)
if not total_count or total_count < 0 or total_count % 1 ~= 0 then
error("BadgeAwardQueryFailed: badge award count unavailable", 0)
end
-- `total LEFT JOIN page` yields one row with a NULL page when the page is empty.
if rows[1].uri == nil then rows = {} end
end
local more = #rows > limit
if more then rows[#rows] = nil end
local views, author_views = {}, {}
Expand All @@ -259,13 +305,13 @@ local function list_badge_awards(authors, badge_uris, badge_types, subjects, res
local last = rows[#rows]
next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri })
end
return views, next_cursor
return views, next_cursor, total_count
end

local function handle_list_badge_awards()
keys_only(params, {
authors = true, badgeUris = true, badgeTypes = true, subjects = true, responses = true,
sortDirection = true, limit = true, cursor = true,
sortDirection = true, limit = true, cursor = true, includeTotal = true,
})
local authors = array_values("authors", valid_did, "valid DIDs")
local badge_uris = array_values("badgeUris", valid_badge_definition_uri, "badge-definition AT-URIs")
Expand All @@ -276,9 +322,12 @@ local function handle_list_badge_awards()
local limit = parse_list_limit(params)
local direction = parse_sort_direction(params)
local cursor = decode_badge_award_cursor(scalar(params, "cursor"), direction)
local views, next_cursor = list_badge_awards(authors, badge_uris, badge_types, subjects, responses, limit, cursor, direction)
local include_total = parse_include_total()
local views, next_cursor, total_count = list_badge_awards(authors, badge_uris, badge_types, subjects, responses,
limit, cursor, direction, include_total)
local response = { badgeAwards = toarray(views) }
if next_cursor then response.cursor = next_cursor end
if include_total then response.totalCount = total_count end
return response
end

Expand Down
Loading
Loading