diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 9358f67..a79ef43 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -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. | diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index f8d03a6..d5c956e 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -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": { @@ -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." } } }, diff --git a/.changeset/badge-award-total-count.md b/.changeset/badge-award-total-count.md new file mode 100644 index 0000000..0335964 --- /dev/null +++ b/.changeset/badge-award-total-count.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15774c8..cc2a05a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`. diff --git a/docs/openapi.json b/docs/openapi.json index 1fddbc4..54ca660 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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": { @@ -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 } } }, diff --git a/docs/sources/lexicons/app.certified.badge.listBadgeAwards.json b/docs/sources/lexicons/app.certified.badge.listBadgeAwards.json index 01137d1..0f92c77 100644 --- a/docs/sources/lexicons/app.certified.badge.listBadgeAwards.json +++ b/docs/sources/lexicons/app.certified.badge.listBadgeAwards.json @@ -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." } } }, @@ -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." } } } diff --git a/happyview-modules/lexicons/app.certified.badge.listBadgeAwards.json b/happyview-modules/lexicons/app.certified.badge.listBadgeAwards.json index 399f1cd..404c115 100644 --- a/happyview-modules/lexicons/app.certified.badge.listBadgeAwards.json +++ b/happyview-modules/lexicons/app.certified.badge.listBadgeAwards.json @@ -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." } } }, @@ -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." } } } diff --git a/happyview-modules/lua/endpoints/listBadgeAwards.lua b/happyview-modules/lua/endpoints/listBadgeAwards.lua index b5d0df3..1bccbc3 100644 --- a/happyview-modules/lua/endpoints/listBadgeAwards.lua +++ b/happyview-modules/lua/endpoints/listBadgeAwards.lua @@ -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 @@ -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, " .. @@ -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 = {}, {} @@ -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") @@ -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 diff --git a/happyview-modules/lua/src/listBadgeAwards.lua b/happyview-modules/lua/src/listBadgeAwards.lua index 0ec48fe..7ad2b1e 100644 --- a/happyview-modules/lua/src/listBadgeAwards.lua +++ b/happyview-modules/lua/src/listBadgeAwards.lua @@ -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 @@ -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, " .. @@ -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 = {}, {} @@ -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") @@ -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 diff --git a/happyview-modules/tests/http/badge-queries.http.test.js b/happyview-modules/tests/http/badge-queries.http.test.js index 2748dd8..f500103 100644 --- a/happyview-modules/tests/http/badge-queries.http.test.js +++ b/happyview-modules/tests/http/badge-queries.http.test.js @@ -405,6 +405,84 @@ test("listBadgeAwards combines discriminating filters and traverses ties in both ); }); +test("listBadgeAwards totalCount counts filtered awards before cursor and limit only when requested", async () => { + const filters = { authors: [badgeQueryDids.publisher], badgeUris: [definitionUri] }; + const counts = [ + [{}, 6], + [filters, 4], + // awardD references an unavailable definition version, so badgeTypes excludes it. + [{ ...filters, badgeTypes: ["certification"] }, 3], + [ + { + ...filters, + badgeTypes: ["certification"], + subjects: [badgeQueryDids.recipient, recordSubjectUri], + responses: ["accepted", "rejected"], + }, + 2, + ], + [{ ...filters, badgeTypes: ["recognition"] }, 0], + ]; + for (const [params, expected] of counts) { + const { response, body } = await requestBadgeEndpoint(endpoints.listBadgeAwards, { + ...params, + limit: 1, + includeTotal: true, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.totalCount, expected, JSON.stringify(params)); + assert.equal(body.badgeAwards.length, Math.min(expected, 1)); + } + + const firstPage = await requestBadgeEndpoint(endpoints.listBadgeAwards, { + ...filters, + sortDirection: "asc", + limit: 3, + includeTotal: true, + }); + assert.equal(firstPage.response.status, 200, JSON.stringify(firstPage.body)); + assert.equal(firstPage.body.totalCount, 4); + const lastPage = await requestBadgeEndpoint(endpoints.listBadgeAwards, { + ...filters, + sortDirection: "asc", + limit: 3, + cursor: firstPage.body.cursor, + includeTotal: true, + }); + assert.equal(lastPage.response.status, 200, JSON.stringify(lastPage.body)); + assert.deepEqual( + lastPage.body.badgeAwards.map(({ uri }) => uri), + [canonicalRecords.awardD.uri], + ); + assert.equal(lastPage.body.totalCount, 4, "the cursor does not reduce the count"); + + const pastTheEnd = Buffer.from( + JSON.stringify({ + v: 1, + d: "asc", + t: "2999-01-01T00:00:00.000000Z", + u: canonicalRecords.awardD.uri, + }), + ).toString("hex"); + const empty = await requestBadgeEndpoint(endpoints.listBadgeAwards, { + ...filters, + sortDirection: "asc", + cursor: pastTheEnd, + includeTotal: true, + }); + assert.equal(empty.response.status, 200, JSON.stringify(empty.body)); + assert.deepEqual(empty.body.badgeAwards, []); + assert.equal(Object.hasOwn(empty.body, "cursor"), false); + assert.equal(empty.body.totalCount, 4); + + for (const params of [filters, { ...filters, includeTotal: false }]) { + const { response, body } = await requestBadgeEndpoint(endpoints.listBadgeAwards, params); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.badgeAwards.length, 4); + assert.equal(Object.hasOwn(body, "totalCount"), false); + } +}); + test("listBadgeAwards returns InvalidRequest through the pinned runtime error response", async () => { await assertRuntimeError( endpoints.listBadgeAwards, diff --git a/happyview-modules/tests/unit/tooling/badge-queries.test.js b/happyview-modules/tests/unit/tooling/badge-queries.test.js index 3de33b4..4eb63ed 100644 --- a/happyview-modules/tests/unit/tooling/badge-queries.test.js +++ b/happyview-modules/tests/unit/tooling/badge-queries.test.js @@ -546,6 +546,95 @@ assert(calls[1].sql:find("COALESCE(recipient_response.record::jsonb->>'response' }); }); +test("listBadgeAwards counts matched awards before cursor and limit only when includeTotal is true", async () => { + const badgeUri = `at://${issuer}/${definitionCollection}/forest`; + const awardRow = (rkey, createdAt) => ({ + uri: `at://${issuer}/${awardCollection}/${rkey}`, + did: issuer, + cid: `bafy-${rkey}`, + indexed_at: indexedAt, + record: `award-${rkey}`, + record_json: { + $type: awardCollection, + badge: { uri: badgeUri, cid: "bafy-definition-v1" }, + subject: "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb", + createdAt, + }, + sort_timestamp: createdAt.replace("Z", ".000000Z"), + total_count: 7, + }); + const cursor = Buffer.from( + JSON.stringify({ + v: 1, + d: "desc", + t: "2025-01-05T00:00:00.000000Z", + u: `at://${issuer}/${awardCollection}/cursor`, + }), + ).toString("hex"); + + runLua(await handlerSource("listBadgeAwards"), { + params: { authors: [issuer], badgeUris: [badgeUri], limit: "1", cursor, includeTotal: "true" }, + queryResults: [ + [awardRow("two", "2025-01-02T00:00:00Z"), awardRow("one", "2025-01-01T00:00:00Z")], + [], + [], + ], + assertions: ` +assert(#result.badgeAwards == 1 and result.cursor ~= nil) +assert(result.totalCount == 7 and type(result.totalCount) == 'number') +local sql = calls[1].sql +local matched = assert(sql:find('WITH matched AS (SELECT sorted.sort_at, award.uri', 1, true)) +local total = assert(sql:find('total AS (SELECT COUNT(*) AS total_count FROM matched)', 1, true)) +local page = assert(sql:find('page AS (SELECT * FROM matched WHERE (sort_at, uri) < ', 1, true)) +assert(matched < total and total < page) +assert(sql:find('FROM total LEFT JOIN page ON TRUE', 1, true)) +local matched_sql = sql:sub(matched, total) +assert(matched_sql:find('award.did IN', 1, true) and matched_sql:find("award.record::jsonb->'badge'->>'uri' IN", 1, true), + 'filters belong to the shared matched set') +assert(not matched_sql:find('timestamptz, $', 1, true), 'the cursor is not applied to the count') +assert(not matched_sql:find(' LIMIT $', 1, true), 'the limit is not applied to the count') +assert(calls[1].values[#calls[1].values] == 2) +`, + }); + + runLua(await handlerSource("listBadgeAwards"), { + params: { authors: [issuer], cursor, includeTotal: true }, + queryResults: [[{ total_count: 3, record: "none", record_json: {} }]], + assertions: ` +assert(#result.badgeAwards == 0 and result.cursor == nil) +assert(result.totalCount == 3, 'a past-the-end page still carries the total') +`, + }); + + for (const includeTotal of [undefined, "false"]) { + runLua(await handlerSource("listBadgeAwards"), { + params: includeTotal ? { authors: [issuer], includeTotal } : { authors: [issuer] }, + assertions: ` +assert(result.totalCount == nil) +assert(not calls[1].sql:find('COUNT(*)', 1, true) and not calls[1].sql:find('WITH matched', 1, true)) +`, + }); + } + + for (const totalCount of [undefined, -1, 1.5]) { + runLua(await handlerSource("listBadgeAwards"), { + params: { includeTotal: "true" }, + queryResults: [ + totalCount === undefined + ? [{ uri: "unused", record: "unused", record_json: {} }] + : [{ total_count: totalCount, record: "none", record_json: {} }], + ], + expectedError: "BadgeAwardQueryFailed: badge award count unavailable", + }); + } + + runLua(await handlerSource("listBadgeAwards"), { + params: { includeTotal: "yes" }, + expectedError: "InvalidRequest: includeTotal must be true or false", + assertions: "assert(#calls == 0)", + }); +}); + test("listBadgeAwards rejects invalid subject identifiers before querying", async () => { runLua(await handlerSource("listBadgeAwards"), { params: { subjects: ["did:plc:bbbbbbbbbbbbbbbbbbbbbbbb", "recipient.example"] }, diff --git a/packages/api/src/lexicons/app/certified/badge/listBadgeAwards.defs.ts b/packages/api/src/lexicons/app/certified/badge/listBadgeAwards.defs.ts index 441519e..1bb870e 100644 --- a/packages/api/src/lexicons/app/certified/badge/listBadgeAwards.defs.ts +++ b/packages/api/src/lexicons/app/certified/badge/listBadgeAwards.defs.ts @@ -11,7 +11,7 @@ type $nsid = typeof $nsid; export { $nsid }; -export const $params = /*#__PURE__*/ l.params({"authors":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.string({"format":"did"}), {"maxLength":100})),"badgeUris":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.string({"format":"at-uri"}), {"maxLength":100})),"badgeTypes":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.string({"maxLength":100}), {"maxLength":100})),"subjects":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.string(), {"maxLength":100})),"responses":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.enum(["accepted","rejected","unanswered"]), {"maxLength":100})),"sortDirection":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.enum(["asc","desc"]), "desc")),"limit":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.integer({"minimum":1,"maximum":100}), 25)),"cursor":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.string())}); +export const $params = /*#__PURE__*/ l.params({"authors":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.string({"format":"did"}), {"maxLength":100})),"badgeUris":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.string({"format":"at-uri"}), {"maxLength":100})),"badgeTypes":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.string({"maxLength":100}), {"maxLength":100})),"subjects":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.string(), {"maxLength":100})),"responses":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.array(/*#__PURE__*/ l.enum(["accepted","rejected","unanswered"]), {"maxLength":100})),"sortDirection":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.enum(["asc","desc"]), "desc")),"limit":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.integer({"minimum":1,"maximum":100}), 25)),"cursor":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),"includeTotal":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.boolean(), false))}); export type $Params = l.InferOutput; @@ -36,10 +36,15 @@ type Output = { $type?: "app.certified.badge.listBadgeAwards#output";"badgeAward /** * Opaque cursor for the next page; omitted when pagination is complete. */ - "cursor"?:string }; + "cursor"?:string; + + /** + * Number of badge award records matching all filters, before cursor and limit are applied. Not deduplicated. Only returned when includeTotal is true. + */ + "totalCount"?:number }; export type { Output }; -const output = /*#__PURE__*/ l.typedObject($nsid, "output", /*#__PURE__*/ l.object({"badgeAwards":/*#__PURE__*/ l.array(/*#__PURE__*/ l.ref((() => BadgeGetBadgeAward.badgeAwardView) as any), ),"cursor":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.string())})); +const output = /*#__PURE__*/ l.typedObject($nsid, "output", /*#__PURE__*/ l.object({"badgeAwards":/*#__PURE__*/ l.array(/*#__PURE__*/ l.ref((() => BadgeGetBadgeAward.badgeAwardView) as any), ),"cursor":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),"totalCount":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.integer({"minimum":0}))})); export { output };