From caaa349918a9bb64b6d93e0ea5d13d267ad2500b Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 14 Oct 2026 13:04:16 +0600 Subject: [PATCH] api: add batched reaction counts query --- .agents/skills/hypercerts-api/SKILL.md | 2 +- .../hypercerts-api/references/endpoint-map.md | 5 +- .../hypercerts-api/references/openapi.json | 3 + .../references/request-patterns.md | 2 + .changeset/batch-reaction-counts.md | 5 + api/README.md | 4 +- .../app.certified.feed.getReactionCounts.json | 69 +++++++ api/lua/endpoints/getReactionCounts.lua | 180 ++++++++++++++++++ api/lua/src/getReactionCounts.lua | 139 ++++++++++++++ api/manifest.json | 4 + api/modules/feed-reactions/manifest.json | 20 ++ api/tests/http/feed-reactions.http.test.js | 24 +++ api/tests/unit/tooling/feed-reactions.test.js | 72 +++++++ docs/coverage.json | 12 +- docs/openapi.json | 95 +++++++++ docs/sources/index.json | 18 ++ .../app.certified.feed.getReactionCounts.json | 86 +++++++++ 17 files changed, 732 insertions(+), 8 deletions(-) create mode 100644 .changeset/batch-reaction-counts.md create mode 100644 api/lexicons/app.certified.feed.getReactionCounts.json create mode 100644 api/lua/endpoints/getReactionCounts.lua create mode 100644 api/lua/src/getReactionCounts.lua create mode 100644 docs/sources/lexicons/app.certified.feed.getReactionCounts.json diff --git a/.agents/skills/hypercerts-api/SKILL.md b/.agents/skills/hypercerts-api/SKILL.md index 4ab77df..270029e 100644 --- a/.agents/skills/hypercerts-api/SKILL.md +++ b/.agents/skills/hypercerts-api/SKILL.md @@ -20,7 +20,7 @@ Read the relevant reference, then inspect the exact operation in the [bundled Op ## Bundled API contract -- [`references/openapi.json`](references/openapi.json) is a self-contained snapshot of all 59 query operations, including parameters, response schemas, declared XRPC errors, and their referenced schema definitions. The included contract makes the skill self-contained when installed separately. +- [`references/openapi.json`](references/openapi.json) is a self-contained snapshot of all 60 query operations, including parameters, response schemas, declared XRPC errors, and their referenced schema definitions. The included contract makes the skill self-contained when installed separately. - To inspect an exact contract, find `/xrpc/` in the snapshot, read its operation, and follow any `#/components/schemas/...` references by schema name. Each operation and schema is stored on its own line for focused lookup. - The snapshot describes declared behavior, not endpoint availability or data freshness on a particular host. Its OpenAPI `info.version` is not the deployed server's version. - Use the API directly when a task needs live indexed results. Do not send extra diagnostic probes just to validate deployment; a deployment-specific availability check requires explicit approval for the target. diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index de40fe2..c87bf6d 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -1,6 +1,6 @@ # Endpoint map: choose by consumer task -This task index lists the 59 public query NSIDs in the bundled [OpenAPI contract](openapi.json). It helps choose an operation but does not replace its exact contract: inspect that operation and its referenced schemas for required parameters, types, defaults, limits, filter semantics, response fields, and declared errors. The static snapshot describes the contract, not availability or freshness on a selected deployment. +This task index lists the 60 public query NSIDs in the bundled [OpenAPI contract](openapi.json). It helps choose an operation but does not replace its exact contract: inspect that operation and its referenced schemas for required parameters, types, defaults, limits, filter semantics, response fields, and declared errors. The static snapshot describes the contract, not availability or freshness on a selected deployment. ## Fast selection rules @@ -83,7 +83,7 @@ An `unanswered` award is a successful indexed award result, not a not-found resp Do not confuse actor follows with entity follows; their identifiers and list outputs differ. Recent raw follows preserve duplicates and are not a snapshot; consult that endpoint's description before using cursors. -## Feed reactions (4) +## Feed reactions (5) | User task | Query NSID | Practical choice | |---|---|---| @@ -91,6 +91,7 @@ Do not confuse actor follows with entity follows; their identifiers and list out | List actors who reposted a record | `app.certified.feed.getReposts` | `subject` is a full record AT-URI; matching ignores CID. The response includes a total distinct-actor count, paginated actor sidecars, and each actor's raw representative repost. | | List an actor's likes | `app.certified.feed.getActorLikes` | `actor` is a repository-owner DID. Returns raw like records, one per subject URI; subjects need not be indexed. | | List an actor's reposts | `app.certified.feed.getActorReposts` | `actor` is a repository-owner DID. Returns raw repost records, one per subject URI; subjects need not be indexed. | +| Get reaction counts for subjects | `app.certified.feed.getReactionCounts` | Pass 1–100 full subject AT-URIs as repeated, unbracketed `subjects` parameters. Results preserve input order and duplicates; counts are distinct indexed publisher DIDs matching subject URI regardless of CID or subject-record indexing. | Reaction queries use the earliest record per author/subject URI, with URI ascending as the tie-breaker. They sort pages by the representative record's `createdAt` and URI; check each operation for exact cursor and response details. diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index 8b4da9d..17028c3 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -23,6 +23,7 @@ "/xrpc/app.certified.feed.getActorReposts": {"get":{"operationId":"app_certified_feed_getActorReposts","tags":["app.certified.feed"],"description":"Lists an actor's indexed reposts, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw repost records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.","parameters":[{"name":"actor","in":"query","required":true,"description":"Repository-owner DID of the actor whose reposts to list.","schema":{"type":"string","format":"did"}},{"name":"sortDirection","in":"query","required":false,"description":"Sort by representative repost createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getActorReposts.output"}}}}},"x-lexicon-id":"app.certified.feed.getActorReposts","x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID, sort direction, limit, cursor, or another query parameter is invalid."},{"name":"RepostQueryFailed","description":"The actor reposts query failed; retry the request."}]}}, "/xrpc/app.certified.feed.getLikes": {"get":{"operationId":"app_certified_feed_getLikes","tags":["app.certified.feed"],"description":"Lists distinct actors liking a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.","parameters":[{"name":"subject","in":"query","required":true,"description":"Full DID-authority record AT-URI; any target collection is accepted.","schema":{"type":"string","format":"at-uri"}},{"name":"sortDirection","in":"query","required":false,"description":"Sort by representative like createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getLikes.output"}}}}},"x-lexicon-id":"app.certified.feed.getLikes","x-lexicon-errors":[{"name":"InvalidRequest","description":"The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid."},{"name":"LikeQueryFailed","description":"The like query, total count, or required actor hydration failed; retry the request."}]}}, "/xrpc/app.certified.feed.getReposts": {"get":{"operationId":"app_certified_feed_getReposts","tags":["app.certified.feed"],"description":"Lists distinct actors reposting a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.","parameters":[{"name":"subject","in":"query","required":true,"description":"Full DID-authority record AT-URI; any target collection is accepted.","schema":{"type":"string","format":"at-uri"}},{"name":"sortDirection","in":"query","required":false,"description":"Sort by representative repost createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getReposts.output"}}}}},"x-lexicon-id":"app.certified.feed.getReposts","x-lexicon-errors":[{"name":"InvalidRequest","description":"The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid."},{"name":"RepostQueryFailed","description":"The repost query, total count, or required actor hydration failed; retry the request."}]}}, + "/xrpc/app.certified.feed.getReactionCounts": {"get":{"operationId":"app_certified_feed_getReactionCounts","tags":["app.certified.feed"],"description":"Returns current indexed like and repost counts for a list of subject records. No authentication is required. Counts are distinct publisher DIDs and match subject.uri regardless of CID or whether the subject is indexed.","parameters":[{"name":"subjects","in":"query","required":true,"description":"Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters.","schema":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getReactionCounts.output"}}}}},"x-lexicon-id":"app.certified.feed.getReactionCounts","x-lexicon-errors":[{"name":"InvalidRequest","description":"The subjects list is empty, has more than 100 entries, or contains an invalid subject AT-URI; correct the list and retry."},{"name":"ReactionCountQueryFailed","description":"The indexed like or repost count query failed; retry the request."}]}}, "/xrpc/app.certified.graph.getEntityFollow": {"get":{"operationId":"app_certified_graph_getEntityFollow","tags":["app.certified.graph"],"description":"Returns the representative entity-follow record for an actor and entity AT-URI, or null if none is indexed. No authentication is required; lookup failures return errors rather than null.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID that published the entity-follow record.","schema":{"type":"string","format":"did"}},{"name":"entity","in":"query","required":true,"description":"Full AT-URI of the followed record using a DID authority; any target collection is accepted, and matching is by URI, not CID.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.graph.getEntityFollow.output"}}}}},"x-lexicon-id":"app.certified.graph.getEntityFollow","x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID, entity AT-URI, or another query parameter is invalid."}]}}, "/xrpc/app.certified.graph.getFollow": {"get":{"operationId":"app_certified_graph_getFollow","tags":["app.certified.graph"],"description":"Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID of the actor who published the follow record.","schema":{"type":"string","format":"did"}},{"name":"subject","in":"query","required":true,"description":"DID named as the follow record's subject.","schema":{"type":"string","format":"did"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.graph.getFollow.output"}}}}},"x-lexicon-id":"app.certified.graph.getFollow","x-lexicon-errors":[{"name":"InvalidRequest","description":"A DID or query parameter is invalid."}]}}, "/xrpc/app.certified.graph.listActorFollowers": {"get":{"operationId":"app_certified_graph_listActorFollowers","tags":["app.certified.graph"],"description":"Lists actors whose follow records name the requested DID as subject. No authentication is required; duplicate relationships are collapsed before pagination, and only the returned actors are hydrated.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID whose incoming follows are listed.","schema":{"type":"string","format":"did"}},{"name":"sortDirection","in":"query","required":false,"description":"Sorts by representative follow createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep the other query parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.graph.listActorFollowers.output"}}}}},"x-lexicon-id":"app.certified.graph.listActorFollowers","x-lexicon-errors":[{"name":"InvalidRequest","description":"A DID or query parameter is invalid."}]}}, @@ -108,6 +109,8 @@ "app.certified.feed.getLikes.output": {"type":"object","required":["likeCount","likes"],"properties":{"likeCount":{"type":"integer","description":"Total distinct indexed author DIDs liking this subject URI, independent of page size. Zero when no likes are indexed; totals may change between requests.","minimum":0},"likes":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.feed.getLikes.likeView"}},"cursor":{"type":"string","description":"Next-page cursor; omitted when pagination is complete."}}}, "app.certified.feed.getReposts.repostView": {"type":"object","description":"Reposter actor sidecars paired with the representative raw repost record. Missing profile and organization records are null.","required":["did","profile","organization","repost"],"properties":{"did":{"type":"string","format":"did"},"profile":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.profileView"},{"type":"null"}]},"organization":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.organizationView"},{"type":"null"}]},"repost":{"$ref":"#/components/schemas/app.certified.feed.getReposts.repostRecordView"}}}, "app.certified.feed.getReposts.output": {"type":"object","required":["repostCount","reposts"],"properties":{"repostCount":{"type":"integer","description":"Total distinct indexed author DIDs reposting this subject URI, independent of page size. Zero when no reposts are indexed; totals may change between requests.","minimum":0},"reposts":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.feed.getReposts.repostView"}},"cursor":{"type":"string","description":"Next-page cursor; omitted when pagination is complete."}}}, + "app.certified.feed.getReactionCounts.subjectReactionCount": {"type":"object","required":["subject","likeCount","repostCount"],"properties":{"subject":{"type":"string","description":"Subject AT-URI requested by the caller; matching ignores the subject CID.","format":"at-uri"},"likeCount":{"type":"integer","description":"Number of distinct indexed publisher DIDs with a current like whose subject.uri matches this URI; zero when no matching likes are indexed.","minimum":0},"repostCount":{"type":"integer","description":"Number of distinct indexed publisher DIDs with a current repost whose subject.uri matches this URI; zero when no matching reposts are indexed.","minimum":0}}}, + "app.certified.feed.getReactionCounts.output": {"type":"object","required":["counts"],"properties":{"counts":{"type":"array","description":"One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests.","items":{"$ref":"#/components/schemas/app.certified.feed.getReactionCounts.subjectReactionCount"}}}}, "app.certified.graph.entityFollow.main": {"type":"record","description":"Record declaring a social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future."}, "app.certified.graph.getEntityFollow.entityFollowRecordView": {"type":"object","description":"Raw indexed entity-follow record view, including the DID that published the relationship.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.graph.entityFollow.main"}}}, "app.certified.graph.getEntityFollow.output": {"type":"object","required":["follow"],"properties":{"follow":{"anyOf":[{"$ref":"#/components/schemas/app.certified.graph.getEntityFollow.entityFollowRecordView"},{"type":"null"}]}}}, diff --git a/.agents/skills/hypercerts-api/references/request-patterns.md b/.agents/skills/hypercerts-api/references/request-patterns.md index 7d4364e..0198131 100644 --- a/.agents/skills/hypercerts-api/references/request-patterns.md +++ b/.agents/skills/hypercerts-api/references/request-patterns.md @@ -37,6 +37,8 @@ Do not use `authors[]=` or comma-joined values unless that exact endpoint contra A useful starting distinction: arrays often mean “any of these” for a filter, while separate filters may combine with AND, but this is **not a universal rule**. For example, the bundled collection contract says `tagUris` requires every supplied tag URI, while `itemUris` matches URI without CID. Confirm the exact operation before relying on filter logic. +`app.certified.feed.getReactionCounts` accepts 1–100 subject AT-URIs. Append each value under the unbracketed `subjects` key; do not use `subjects[]` or comma-join values. Its response has one entry per input occurrence in the same order, including duplicates. A zero count means no matching indexed reaction publishers were found at query time; counts may change as indexing changes. + ## Keep page requests cursor-consistent For a list/search operation whose output has a cursor, keep a base set of all non-cursor parameters and replace only the cursor on continuation. Preserve repeated array values, filters, `search`, `limit`, and `sortDirection` exactly. The example below is illustrative; verify the chosen endpoint's cursor binding and response-array name. diff --git a/.changeset/batch-reaction-counts.md b/.changeset/batch-reaction-counts.md new file mode 100644 index 0000000..92efefc --- /dev/null +++ b/.changeset/batch-reaction-counts.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': minor +--- + +Add a public query that returns like and repost counts for up to 100 subject records at a time. diff --git a/api/README.md b/api/README.md index b1fb4cb..d1daf59 100644 --- a/api/README.md +++ b/api/README.md @@ -8,9 +8,9 @@ Use the [`docs/` endpoint explorer](../docs/) and generated [OpenAPI reference]( ## Feed reaction capability -`modules/feed-reactions/manifest.json` owns the like and repost record schemas and four public queries: `app.certified.feed.getLikes`, `getReposts`, `getActorLikes`, and `getActorReposts`. The root manifest includes this independently installable module in the aggregate bundle. Its record schemas enable backfill, and queries read the current indexed records directly; deleted reactions therefore stop appearing once removed from HappyView's index, without a separate stored count. +`modules/feed-reactions/manifest.json` owns the like and repost record schemas and five public queries: `app.certified.feed.getLikes`, `getReposts`, `getActorLikes`, `getActorReposts`, and `getReactionCounts`. The root manifest includes this independently installable module in the aggregate bundle. Its record schemas enable backfill, and queries read the current indexed records directly; deleted reactions therefore stop appearing once removed from HappyView's index, without a separate stored count. -Subject queries match by AT-URI without requiring a subject CID match, return one earliest reaction per author, and include a distinct-actor total with nullable profile and organization sidecars. Actor queries return raw reaction records without subject hydration. All four use bounded `createdAt`/URI cursor pagination. +Subject queries match by AT-URI without requiring a subject CID match, return one earliest reaction per author, and include a distinct-actor total with nullable profile and organization sidecars. Actor queries return raw reaction records without subject hydration. `getReactionCounts` accepts 1–100 repeated, unbracketed `subjects` values and returns one ordered count pair per input occurrence, counting distinct indexed publisher DIDs by subject URI across both reaction collections. Its counts do not require the subject record to be indexed and may change as reaction indexing changes. The other four queries use bounded `createdAt`/URI cursor pagination. ## Local validation diff --git a/api/lexicons/app.certified.feed.getReactionCounts.json b/api/lexicons/app.certified.feed.getReactionCounts.json new file mode 100644 index 0000000..cc93887 --- /dev/null +++ b/api/lexicons/app.certified.feed.getReactionCounts.json @@ -0,0 +1,69 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getReactionCounts", + "defs": { + "main": { + "type": "query", + "description": "Returns current indexed like and repost counts for a list of subject records. No authentication is required. Counts are distinct publisher DIDs and match subject.uri regardless of CID or whether the subject is indexed.", + "parameters": { + "type": "params", + "required": ["subjects"], + "properties": { + "subjects": { + "type": "array", + "minLength": 1, + "maxLength": 100, + "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters.", + "items": { "type": "string", "format": "at-uri" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, or contains an invalid subject AT-URI; correct the list and retry." + }, + { + "name": "ReactionCountQueryFailed", + "description": "The indexed like or repost count query failed; retry the request." + } + ] + }, + "subjectReactionCount": { + "type": "object", + "required": ["subject", "likeCount", "repostCount"], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "Subject AT-URI requested by the caller; matching ignores the subject CID." + }, + "likeCount": { + "type": "integer", + "minimum": 0, + "description": "Number of distinct indexed publisher DIDs with a current like whose subject.uri matches this URI; zero when no matching likes are indexed." + }, + "repostCount": { + "type": "integer", + "minimum": 0, + "description": "Number of distinct indexed publisher DIDs with a current repost whose subject.uri matches this URI; zero when no matching reposts are indexed." + } + } + }, + "output": { + "type": "object", + "required": ["counts"], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { "type": "ref", "ref": "#subjectReactionCount" } + } + } + } + } +} diff --git a/api/lua/endpoints/getReactionCounts.lua b/api/lua/endpoints/getReactionCounts.lua new file mode 100644 index 0000000..15d1919 --- /dev/null +++ b/api/lua/endpoints/getReactionCounts.lua @@ -0,0 +1,180 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local LIKE = "app.certified.feed.like" +local REPOST = "app.certified.feed.repost" + +local function reaction_count_valid_did_percent_escapes(authority) + local index = 1 + while index <= #authority do + if authority:sub(index, index) == "%" then + if not authority:sub(index + 1, index + 2):match("^%x%x$") then return false end + index = index + 3 + else + index = index + 1 + end + end + return true +end + +local function reaction_count_valid_nsid(value) + if type(value) ~= "string" or #value > 317 then return false end + local segments = {} + for segment in value:gmatch("[^%.]+") do segments[#segments + 1] = segment end + if #segments < 3 or table.concat(segments, ".") ~= value then return false end + for index = 1, #segments - 1 do + local segment = segments[index] + if #segment > 63 or segment:find("[^A-Za-z0-9%-]") then return false end + local first, last = segment:sub(1, 1), segment:sub(-1) + if not first:match(index == 1 and "^[A-Za-z]$" or "^[A-Za-z0-9]$") + or not last:match("^[A-Za-z0-9]$") then return false end + end + local name = segments[#segments] + return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil +end + +local function reaction_count_subjects() + local value = params.subjects + if value == nil then invalid("subjects is required") end + + local supplied = {} + if type(value) == "string" then + supplied[1] = scalar(params, "subjects") + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid("subjects must use repeated query values") + end + count = count + 1 + if count > 100 then invalid("subjects must contain 1 through 100 AT-URIs") end + end + if count ~= #value then invalid("subjects must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid("subjects entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid("subjects must be a string or repeated string parameter") + end + + if #supplied < 1 or #supplied > 100 then + invalid("subjects must contain 1 through 100 AT-URIs") + end + + local unique, seen = {}, {} + for _, subject in ipairs(supplied) do + local valid, collection, authority = valid_record_uri(subject) + if not valid or not reaction_count_valid_did_percent_escapes(authority) + or not reaction_count_valid_nsid(collection) then + invalid("each subjects value must be a full AT-URI with a valid DID authority and collection NSID") + end + if not seen[subject] then + seen[subject] = true + unique[#unique + 1] = subject + end + end + return supplied, unique +end + +local function reaction_count_query(subjects) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ReactionCountQueryFailed: reaction counts require PostgreSQL", 0) + end + + local values = { LIKE, REPOST } + local placeholders = {} + for _, subject in ipairs(subjects) do + values[#values + 1] = subject + placeholders[#placeholders + 1] = "$" .. #values + end + + local subject_uri = "record::jsonb->'subject'->>'uri'" + local sql = "SELECT " .. subject_uri .. " AS subject_uri, " .. + "COUNT(DISTINCT did) FILTER (WHERE collection = $1) AS like_count, " .. + "COUNT(DISTINCT did) FILTER (WHERE collection = $2) AS repost_count " .. + "FROM happyview_records WHERE collection IN ($1, $2) AND " .. subject_uri .. + " IN (" .. table.concat(placeholders, ", ") .. ") GROUP BY " .. subject_uri + local ok, rows = pcall(db.raw, sql, values) + if not ok or type(rows) ~= "table" then + error("ReactionCountQueryFailed: indexed reaction count query failed", 0) + end + return rows +end + +local function reaction_count_number(value) + local count = tonumber(value) + if not count or count < 0 or count % 1 ~= 0 then + error("ReactionCountQueryFailed: indexed reaction count was unavailable", 0) + end + return count +end + +local function get_reaction_counts(subjects, unique_subjects) + local counts_by_subject = {} + for _, row in ipairs(reaction_count_query(unique_subjects)) do + if type(row) ~= "table" or type(row.subject_uri) ~= "string" then + error("ReactionCountQueryFailed: indexed reaction count subject was unavailable", 0) + end + counts_by_subject[row.subject_uri] = { + likeCount = reaction_count_number(row.like_count), + repostCount = reaction_count_number(row.repost_count), + } + end + + local counts = {} + for _, subject in ipairs(subjects) do + local indexed = counts_by_subject[subject] + counts[#counts + 1] = { + subject = subject, + likeCount = indexed and indexed.likeCount or 0, + repostCount = indexed and indexed.repostCount or 0, + } + end + return { counts = toarray(counts) } +end + +function handle() + keys_only(params, { subjects = true }) + local subjects, unique_subjects = reaction_count_subjects() + return get_reaction_counts(subjects, unique_subjects) +end diff --git a/api/lua/src/getReactionCounts.lua b/api/lua/src/getReactionCounts.lua new file mode 100644 index 0000000..369219d --- /dev/null +++ b/api/lua/src/getReactionCounts.lua @@ -0,0 +1,139 @@ +local LIKE = "app.certified.feed.like" +local REPOST = "app.certified.feed.repost" + +local function reaction_count_valid_did_percent_escapes(authority) + local index = 1 + while index <= #authority do + if authority:sub(index, index) == "%" then + if not authority:sub(index + 1, index + 2):match("^%x%x$") then return false end + index = index + 3 + else + index = index + 1 + end + end + return true +end + +local function reaction_count_valid_nsid(value) + if type(value) ~= "string" or #value > 317 then return false end + local segments = {} + for segment in value:gmatch("[^%.]+") do segments[#segments + 1] = segment end + if #segments < 3 or table.concat(segments, ".") ~= value then return false end + for index = 1, #segments - 1 do + local segment = segments[index] + if #segment > 63 or segment:find("[^A-Za-z0-9%-]") then return false end + local first, last = segment:sub(1, 1), segment:sub(-1) + if not first:match(index == 1 and "^[A-Za-z]$" or "^[A-Za-z0-9]$") + or not last:match("^[A-Za-z0-9]$") then return false end + end + local name = segments[#segments] + return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil +end + +local function reaction_count_subjects() + local value = params.subjects + if value == nil then invalid("subjects is required") end + + local supplied = {} + if type(value) == "string" then + supplied[1] = scalar(params, "subjects") + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid("subjects must use repeated query values") + end + count = count + 1 + if count > 100 then invalid("subjects must contain 1 through 100 AT-URIs") end + end + if count ~= #value then invalid("subjects must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid("subjects entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid("subjects must be a string or repeated string parameter") + end + + if #supplied < 1 or #supplied > 100 then + invalid("subjects must contain 1 through 100 AT-URIs") + end + + local unique, seen = {}, {} + for _, subject in ipairs(supplied) do + local valid, collection, authority = valid_record_uri(subject) + if not valid or not reaction_count_valid_did_percent_escapes(authority) + or not reaction_count_valid_nsid(collection) then + invalid("each subjects value must be a full AT-URI with a valid DID authority and collection NSID") + end + if not seen[subject] then + seen[subject] = true + unique[#unique + 1] = subject + end + end + return supplied, unique +end + +local function reaction_count_query(subjects) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ReactionCountQueryFailed: reaction counts require PostgreSQL", 0) + end + + local values = { LIKE, REPOST } + local placeholders = {} + for _, subject in ipairs(subjects) do + values[#values + 1] = subject + placeholders[#placeholders + 1] = "$" .. #values + end + + local subject_uri = "record::jsonb->'subject'->>'uri'" + local sql = "SELECT " .. subject_uri .. " AS subject_uri, " .. + "COUNT(DISTINCT did) FILTER (WHERE collection = $1) AS like_count, " .. + "COUNT(DISTINCT did) FILTER (WHERE collection = $2) AS repost_count " .. + "FROM happyview_records WHERE collection IN ($1, $2) AND " .. subject_uri .. + " IN (" .. table.concat(placeholders, ", ") .. ") GROUP BY " .. subject_uri + local ok, rows = pcall(db.raw, sql, values) + if not ok or type(rows) ~= "table" then + error("ReactionCountQueryFailed: indexed reaction count query failed", 0) + end + return rows +end + +local function reaction_count_number(value) + local count = tonumber(value) + if not count or count < 0 or count % 1 ~= 0 then + error("ReactionCountQueryFailed: indexed reaction count was unavailable", 0) + end + return count +end + +local function get_reaction_counts(subjects, unique_subjects) + local counts_by_subject = {} + for _, row in ipairs(reaction_count_query(unique_subjects)) do + if type(row) ~= "table" or type(row.subject_uri) ~= "string" then + error("ReactionCountQueryFailed: indexed reaction count subject was unavailable", 0) + end + counts_by_subject[row.subject_uri] = { + likeCount = reaction_count_number(row.like_count), + repostCount = reaction_count_number(row.repost_count), + } + end + + local counts = {} + for _, subject in ipairs(subjects) do + local indexed = counts_by_subject[subject] + counts[#counts + 1] = { + subject = subject, + likeCount = indexed and indexed.likeCount or 0, + repostCount = indexed and indexed.repostCount or 0, + } + end + return { counts = toarray(counts) } +end + +function handle() + keys_only(params, { subjects = true }) + local subjects, unique_subjects = reaction_count_subjects() + return get_reaction_counts(subjects, unique_subjects) +end diff --git a/api/manifest.json b/api/manifest.json index 7dbe9b9..42ff69a 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -62,6 +62,10 @@ "id": "app.certified.feed.getActorReposts", "path": "lexicons/app.certified.feed.getActorReposts.json" }, + { + "id": "app.certified.feed.getReactionCounts", + "path": "lexicons/app.certified.feed.getReactionCounts.json" + }, { "id": "app.certified.actor.profile", "packagePath": "lexicons/app/certified/actor/profile.json" diff --git a/api/modules/feed-reactions/manifest.json b/api/modules/feed-reactions/manifest.json index 294a7ff..99e7aed 100644 --- a/api/modules/feed-reactions/manifest.json +++ b/api/modules/feed-reactions/manifest.json @@ -42,6 +42,13 @@ "config": { "backfill": false, "target_collection": "app.certified.feed.repost" }, "dependsOn": ["app.certified.feed.getReposts"] }, + { + "kind": "lexicon", + "id": "app.certified.feed.getReactionCounts", + "path": "../../lexicons/app.certified.feed.getReactionCounts.json", + "config": { "backfill": false }, + "dependsOn": ["app.certified.feed.like", "app.certified.feed.repost"] + }, { "kind": "script", "id": "xrpc.query:app.certified.feed.getLikes", @@ -117,6 +124,19 @@ ], "config": { "script_type": "lua", "description": "Feed reaction API handler" }, "dependsOn": ["app.certified.feed.getActorReposts"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.feed.getReactionCounts", + "path": "../../lua/endpoints/getReactionCounts.lua", + "sourcePath": "../../lua/src/getReactionCounts.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua" + ], + "config": { "script_type": "lua", "description": "Batched feed reaction count query handler" }, + "dependsOn": ["app.certified.feed.getReactionCounts"] } ] } diff --git a/api/tests/http/feed-reactions.http.test.js b/api/tests/http/feed-reactions.http.test.js index cec2afb..f09abf3 100644 --- a/api/tests/http/feed-reactions.http.test.js +++ b/api/tests/http/feed-reactions.http.test.js @@ -7,6 +7,7 @@ const likesEndpoint = 'app.certified.feed.getLikes'; const repostsEndpoint = 'app.certified.feed.getReposts'; const actorLikesEndpoint = 'app.certified.feed.getActorLikes'; const actorRepostsEndpoint = 'app.certified.feed.getActorReposts'; +const reactionCountsEndpoint = 'app.certified.feed.getReactionCounts'; const likeCollection = 'app.certified.feed.like'; const repostCollection = 'app.certified.feed.repost'; @@ -137,6 +138,22 @@ test('getReposts returns total distinct reposters and earliest raw reposts with assert.equal(body.reposts[1].organization, null); }); +test('getReactionCounts batches repeated subjects and counts distinct publishers by subject URI only', async () => { + const missingSubject = `at://${reactionDids.target}/org.example.unindexed/missing`; + const { response, body } = await request(reactionCountsEndpoint, { + subjects: [reactionUris.subject, reactionUris.otherSubject, reactionUris.subject, missingSubject], + }); + + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.counts, [ + { subject: reactionUris.subject, likeCount: 3, repostCount: 2 }, + { subject: reactionUris.otherSubject, likeCount: 1, repostCount: 1 }, + { subject: reactionUris.subject, likeCount: 3, repostCount: 2 }, + { subject: missingSubject, likeCount: 0, repostCount: 0 }, + ]); + assert.deepEqual(Object.keys(body), ['counts']); +}); + test('actor reaction queries deduplicate subject URIs and return raw records without hydration or counts', async () => { for (const { endpoint, collection, outputKey, relationKey } of [ { endpoint: actorLikesEndpoint, collection: likeCollection, outputKey: 'likes', relationKey: 'like' }, @@ -198,11 +215,18 @@ test('actor reaction queries keyset-page URI ties in descending order without hy }); test('reaction endpoints expose named request errors for invalid pagination and identifiers', async () => { + const tooManySubjects = Array(101).fill(reactionUris.subject); const cases = [ { endpoint: likesEndpoint, params: { subject: 'alice.example' } }, { endpoint: repostsEndpoint, params: { subject: reactionUris.subject, limit: 101 } }, { endpoint: actorLikesEndpoint, params: { actor: 'alice.example' } }, { endpoint: actorRepostsEndpoint, params: { actor: reactionDids.actorA, sortDirection: 'sideways' } }, + { endpoint: reactionCountsEndpoint, params: {} }, + { endpoint: reactionCountsEndpoint, params: { subjects: ['alice.example'] } }, + { endpoint: reactionCountsEndpoint, params: { subjects: ['at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/invalid/key'] } }, + { endpoint: reactionCountsEndpoint, params: { subjects: ['at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa%GG/org.example.subject/key'] } }, + { endpoint: reactionCountsEndpoint, params: { subjects: tooManySubjects } }, + { endpoint: reactionCountsEndpoint, params: { 'subjects[]': [reactionUris.subject] } }, ]; for (const { endpoint, params } of cases) { const { response, body } = await request(endpoint, params); diff --git a/api/tests/unit/tooling/feed-reactions.test.js b/api/tests/unit/tooling/feed-reactions.test.js index 0ede940..1da2d42 100644 --- a/api/tests/unit/tooling/feed-reactions.test.js +++ b/api/tests/unit/tooling/feed-reactions.test.js @@ -129,6 +129,7 @@ db = { backend = function() return '${backend}' end, raw = function(sql, values) calls[#calls + 1] = { sql = sql, values = values } + if ${failure === 'reaction-counts' ? 'true' : 'false'} then error('injected reaction count query failure') end if ${failure === 'reaction' ? 'true' : 'false'} and values[1] == '${collections.Like}' then error('injected query failure') end if ${failure === 'repost' ? 'true' : 'false'} and values[1] == '${collections.Repost}' then error('injected query failure') end if ${failure === 'profile' ? 'true' : 'false'} and values[1] == '${profileCollection}' then error('injected profile failure') end @@ -220,6 +221,77 @@ assert(calls[1].values[3] == 26, 'subject queries default to 25 and fetch one lo }); }); +test('getReactionCounts batches subjects and preserves duplicate input order with zero counts for unmatched subjects', () => { + const missingSubject = 'at://did:plc:eeeeeeeeeeeeeeeeeeeeeeee/org.example.subject/missing'; + runLua({ + endpoint: 'getReactionCounts', + params: { subjects: [subject, missingSubject, subject] }, + rows: [{ subject_uri: subject, like_count: '2', repost_count: '1' }], + assertions: ` +assert(#result.counts == 3) +assert(result.counts[1].subject == '${subject}' and result.counts[1].likeCount == 2 and result.counts[1].repostCount == 1) +assert(result.counts[2].subject == '${missingSubject}' and result.counts[2].likeCount == 0 and result.counts[2].repostCount == 0) +assert(result.counts[3].subject == '${subject}' and result.counts[3].likeCount == 2 and result.counts[3].repostCount == 1) +assert(#calls == 1, 'one batched database query must serve all requested subject occurrences') +`, + }); +}); + +test('getReactionCounts accepts 100 repeated values but rejects empty, oversized, malformed, and unknown inputs before querying', () => { + runLua({ + endpoint: 'getReactionCounts', + params: { subjects: Array(100).fill(subject) }, + assertions: 'assert(#result.counts == 100 and #calls == 1)', + }); + + const tooManySubjects = Array.from({ length: 101 }, (_, index) => `at://did:plc:ffffffffffffffffffffffff/org.example.subject/${index}`); + for (const params of [ + {}, + { subjects: [] }, + { subjects: tooManySubjects }, + { subjects: ['alice.example'] }, + { subjects: { repeated: true } }, + { subjects: [subject], extra: 'unsupported' }, + ]) { + runLua({ endpoint: 'getReactionCounts', params, expectError: 'InvalidRequest:', expectedCalls: 0 }); + } +}); + +test('getReactionCounts rejects an invalid subject collection NSID before querying', () => { + runLua({ + endpoint: 'getReactionCounts', + params: { subjects: ['at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/invalid/key'] }, + expectError: 'InvalidRequest:', + expectedCalls: 0, + }); +}); + +test('getReactionCounts rejects malformed percent escapes in subject DIDs before querying', () => { + runLua({ + endpoint: 'getReactionCounts', + params: { subjects: ['at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa%GG/org.example.subject/key'] }, + expectError: 'InvalidRequest:', + expectedCalls: 0, + }); +}); + +test('getReactionCounts reports named operational errors for database failures', () => { + runLua({ + endpoint: 'getReactionCounts', + params: { subjects: [subject] }, + failure: 'reaction-counts', + expectError: 'ReactionCountQueryFailed:', + expectedCalls: 1, + }); + runLua({ + endpoint: 'getReactionCounts', + params: { subjects: [subject] }, + backend: 'sqlite', + expectError: 'ReactionCountQueryFailed:', + expectedCalls: 0, + }); +}); + test('actor queries return raw representative records without subject or actor hydration', () => { const like = reactionRow('Like', actor, 'actor-like', '2025-01-03T00:00:00Z'); const repost = reactionRow('Repost', actor, 'actor-repost', '2025-01-02T00:00:00Z'); diff --git a/docs/coverage.json b/docs/coverage.json index 596a9d2..5d1ca81 100644 --- a/docs/coverage.json +++ b/docs/coverage.json @@ -5,12 +5,12 @@ }, "runtimeValidation": "not-assessed", "deploymentValidation": "not-assessed", - "endpointCount": 59, + "endpointCount": 60, "methods": { - "GET": 59 + "GET": 60 }, "inclusion": { - "manifest-registered": 59 + "manifest-registered": 60 }, "unresolvedReferences": [], "endpoints": [ @@ -122,6 +122,12 @@ "inclusion": "manifest-registered", "module": "modules/feed-reactions/manifest.json" }, + { + "id": "app.certified.feed.getReactionCounts", + "method": "GET", + "inclusion": "manifest-registered", + "module": "modules/feed-reactions/manifest.json" + }, { "id": "app.certified.feed.getReposts", "method": "GET", diff --git a/docs/openapi.json b/docs/openapi.json index d1247de..6e203fb 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1508,6 +1508,61 @@ ] } }, + "/xrpc/app.certified.feed.getReactionCounts": { + "get": { + "operationId": "app_certified_feed_getReactionCounts", + "tags": [ + "app.certified.feed" + ], + "description": "Returns current indexed like and repost counts for a list of subject records. No authentication is required. Counts are distinct publisher DIDs and match subject.uri regardless of CID or whether the subject is indexed.", + "parameters": [ + { + "name": "subjects", + "in": "query", + "required": true, + "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters.", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "format": "at-uri" + } + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app.certified.feed.getReactionCounts.output" + } + } + } + } + }, + "x-lexicon-id": "app.certified.feed.getReactionCounts", + "x-hypercerts-coverage": "manifest-registered", + "x-hypercerts-source": { + "module": "modules/feed-reactions/manifest.json" + }, + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, or contains an invalid subject AT-URI; correct the list and retry." + }, + { + "name": "ReactionCountQueryFailed", + "description": "The indexed like or repost count query failed; retry the request." + } + ] + } + }, "/xrpc/app.certified.feed.getReposts": { "get": { "operationId": "app_certified_feed_getReposts", @@ -5799,6 +5854,46 @@ } } }, + "app.certified.feed.getReactionCounts.subjectReactionCount": { + "type": "object", + "required": [ + "subject", + "likeCount", + "repostCount" + ], + "properties": { + "subject": { + "type": "string", + "description": "Subject AT-URI requested by the caller; matching ignores the subject CID.", + "format": "at-uri" + }, + "likeCount": { + "type": "integer", + "description": "Number of distinct indexed publisher DIDs with a current like whose subject.uri matches this URI; zero when no matching likes are indexed.", + "minimum": 0 + }, + "repostCount": { + "type": "integer", + "description": "Number of distinct indexed publisher DIDs with a current repost whose subject.uri matches this URI; zero when no matching reposts are indexed.", + "minimum": 0 + } + } + }, + "app.certified.feed.getReactionCounts.output": { + "type": "object", + "required": [ + "counts" + ], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { + "$ref": "#/components/schemas/app.certified.feed.getReactionCounts.subjectReactionCount" + } + } + } + }, "app.certified.feed.getReposts.repostView": { "type": "object", "description": "Reposter actor sidecars paired with the representative raw repost record. Missing profile and organization records are null.", diff --git a/docs/sources/index.json b/docs/sources/index.json index e12e27e..d14d5e4 100644 --- a/docs/sources/index.json +++ b/docs/sources/index.json @@ -167,6 +167,15 @@ "path": "../../lexicons/app.certified.feed.getLikes.json" } }, + { + "id": "app.certified.feed.getReactionCounts", + "type": "query", + "module": "modules/feed-reactions/manifest.json", + "file": "sources/lexicons/app.certified.feed.getReactionCounts.json", + "source": { + "path": "../../lexicons/app.certified.feed.getReactionCounts.json" + } + }, { "id": "app.certified.feed.getReposts", "type": "query", @@ -756,6 +765,15 @@ "path": "../../lexicons/app.certified.feed.getLikes.json" } }, + { + "id": "app.certified.feed.getReactionCounts", + "file": "sources/lexicons/app.certified.feed.getReactionCounts.json", + "kind": "endpoint", + "source": { + "module": "modules/feed-reactions/manifest.json", + "path": "../../lexicons/app.certified.feed.getReactionCounts.json" + } + }, { "id": "app.certified.feed.getReposts", "file": "sources/lexicons/app.certified.feed.getReposts.json", diff --git a/docs/sources/lexicons/app.certified.feed.getReactionCounts.json b/docs/sources/lexicons/app.certified.feed.getReactionCounts.json new file mode 100644 index 0000000..d669fa1 --- /dev/null +++ b/docs/sources/lexicons/app.certified.feed.getReactionCounts.json @@ -0,0 +1,86 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getReactionCounts", + "defs": { + "main": { + "type": "query", + "description": "Returns current indexed like and repost counts for a list of subject records. No authentication is required. Counts are distinct publisher DIDs and match subject.uri regardless of CID or whether the subject is indexed.", + "parameters": { + "type": "params", + "required": [ + "subjects" + ], + "properties": { + "subjects": { + "type": "array", + "minLength": 1, + "maxLength": 100, + "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters.", + "items": { + "type": "string", + "format": "at-uri" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, or contains an invalid subject AT-URI; correct the list and retry." + }, + { + "name": "ReactionCountQueryFailed", + "description": "The indexed like or repost count query failed; retry the request." + } + ] + }, + "subjectReactionCount": { + "type": "object", + "required": [ + "subject", + "likeCount", + "repostCount" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "Subject AT-URI requested by the caller; matching ignores the subject CID." + }, + "likeCount": { + "type": "integer", + "minimum": 0, + "description": "Number of distinct indexed publisher DIDs with a current like whose subject.uri matches this URI; zero when no matching likes are indexed." + }, + "repostCount": { + "type": "integer", + "minimum": 0, + "description": "Number of distinct indexed publisher DIDs with a current repost whose subject.uri matches this URI; zero when no matching reposts are indexed." + } + } + }, + "output": { + "type": "object", + "required": [ + "counts" + ], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { + "type": "ref", + "ref": "#subjectReactionCount" + } + } + } + } + } +}