From 2835a60cd878cf2ac47d7c4998a1266f70f38e7d Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 11 Nov 2026 21:14:08 +0600 Subject: [PATCH 1/2] lua/shared: extract entityFollowTarget validation from getEntityFollows --- .../lua/endpoints/getEntityFollows.lua | 55 +++++++++++-------- .../lua/shared/entityFollowTarget.lua | 23 ++++++++ .../lua/src/getEntityFollows.lua | 24 +------- .../modules/entity-follow/manifest.json | 1 + 4 files changed, 56 insertions(+), 47 deletions(-) create mode 100644 happyview-modules/lua/shared/entityFollowTarget.lua diff --git a/happyview-modules/lua/endpoints/getEntityFollows.lua b/happyview-modules/lua/endpoints/getEntityFollows.lua index e22319e5..d5a4fe8b 100644 --- a/happyview-modules/lua/endpoints/getEntityFollows.lua +++ b/happyview-modules/lua/endpoints/getEntityFollows.lua @@ -39,6 +39,30 @@ local function valid_record_uri(value) return true, collection, authority end +local function entity_follow_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 _, segment in ipairs(segments) do + if #segment > 63 or segment:find("[^A-Za-z0-9%-]") + or segment:sub(1, 1) == "-" or segment:sub(-1) == "-" then + return false + end + end + + if not segments[1]:sub(1, 1):match("^[A-Za-z]$") then return false end + return segments[#segments]:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil +end + +-- An entity-follow target: a full DID-authority AT-URI with a valid collection NSID. +local function valid_entity_follow_uri(value) + if #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + return valid and entity_follow_valid_nsid(collection) +end + local ENTITY_FOLLOW = "app.certified.graph.entityFollow" local ENTITY_FOLLOW_NULL = json.decode("null") @@ -123,7 +147,12 @@ local function follow_batch_targets(actor, key, value, validate, description, en invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") end - local request_target_length = #("/xrpc/" .. endpoint .. "?actor=") + encoded_query_value_length(actor) + -- actor is nil for batch queries that take only the target list; the first + -- target's "&" then stands in for the "?". + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end local unique, seen = {}, {} for _, target in ipairs(targets) do if type(target) ~= "string" or not validate(target) then @@ -146,30 +175,8 @@ local function get_entity_follows() keys_only(params, { actor = true, entities = true }) local actor = scalar(params, "actor") if not actor or not valid_did(actor) then invalid("actor must be a valid DID") end - local function 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 _, segment in ipairs(segments) do - if #segment > 63 or segment:find("[^A-Za-z0-9%-]") - or segment:sub(1, 1) == "-" or segment:sub(-1) == "-" then - return false - end - end - - if not segments[1]:sub(1, 1):match("^[A-Za-z]$") then return false end - return segments[#segments]:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil - end - - local function valid_entity_uri(value) - if #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) - end local entities, unique_entities = follow_batch_targets( - actor, "entities", params.entities, valid_entity_uri, + actor, "entities", params.entities, valid_entity_follow_uri, "full AT-URI with a DID authority and valid collection NSID", "app.certified.graph.getEntityFollows") local values, requested = { ENTITY_FOLLOW, actor }, {} diff --git a/happyview-modules/lua/shared/entityFollowTarget.lua b/happyview-modules/lua/shared/entityFollowTarget.lua new file mode 100644 index 00000000..073c3d10 --- /dev/null +++ b/happyview-modules/lua/shared/entityFollowTarget.lua @@ -0,0 +1,23 @@ +local function entity_follow_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 _, segment in ipairs(segments) do + if #segment > 63 or segment:find("[^A-Za-z0-9%-]") + or segment:sub(1, 1) == "-" or segment:sub(-1) == "-" then + return false + end + end + + if not segments[1]:sub(1, 1):match("^[A-Za-z]$") then return false end + return segments[#segments]:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil +end + +-- An entity-follow target: a full DID-authority AT-URI with a valid collection NSID. +local function valid_entity_follow_uri(value) + if #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + return valid and entity_follow_valid_nsid(collection) +end diff --git a/happyview-modules/lua/src/getEntityFollows.lua b/happyview-modules/lua/src/getEntityFollows.lua index 148059d1..cf967964 100644 --- a/happyview-modules/lua/src/getEntityFollows.lua +++ b/happyview-modules/lua/src/getEntityFollows.lua @@ -2,30 +2,8 @@ local function get_entity_follows() keys_only(params, { actor = true, entities = true }) local actor = scalar(params, "actor") if not actor or not valid_did(actor) then invalid("actor must be a valid DID") end - local function 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 _, segment in ipairs(segments) do - if #segment > 63 or segment:find("[^A-Za-z0-9%-]") - or segment:sub(1, 1) == "-" or segment:sub(-1) == "-" then - return false - end - end - - if not segments[1]:sub(1, 1):match("^[A-Za-z]$") then return false end - return segments[#segments]:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil - end - - local function valid_entity_uri(value) - if #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) - end local entities, unique_entities = follow_batch_targets( - actor, "entities", params.entities, valid_entity_uri, + actor, "entities", params.entities, valid_entity_follow_uri, "full AT-URI with a DID authority and valid collection NSID", "app.certified.graph.getEntityFollows") local values, requested = { ENTITY_FOLLOW, actor }, {} diff --git a/happyview-modules/modules/entity-follow/manifest.json b/happyview-modules/modules/entity-follow/manifest.json index 870ea335..ecde690e 100644 --- a/happyview-modules/modules/entity-follow/manifest.json +++ b/happyview-modules/modules/entity-follow/manifest.json @@ -72,6 +72,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/entityFollowTarget.lua", "../../lua/shared/entityFollow.lua", "../../lua/shared/followBatch.lua" ], From 45137f271c07db3ca20ad06d051f66d0d1178033 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 11 Nov 2026 21:14:08 +0600 Subject: [PATCH 2/2] graph: add getFollowerCounts batch endpoint --- .../hypercerts-api/references/endpoint-map.md | 4 +- .../hypercerts-api/references/openapi.json | 79 ++++++ .../references/request-patterns.md | 2 + .changeset/batch-follower-counts.md | 6 + CONTRIBUTING.md | 2 +- docs/coverage.json | 12 +- docs/openapi.json | 90 +++++++ docs/sources/index.json | 18 ++ ...app.certified.graph.getFollowerCounts.json | 81 ++++++ happyview-modules/README.md | 2 + ...app.certified.graph.getFollowerCounts.json | 64 +++++ .../lua/endpoints/getFollowerCounts.lua | 233 ++++++++++++++++++ .../lua/endpoints/getFollows.lua | 7 +- happyview-modules/lua/shared/followBatch.lua | 7 +- .../lua/src/getFollowerCounts.lua | 97 ++++++++ happyview-modules/manifest.json | 5 + .../modules/follower-counts/manifest.json | 31 +++ .../tests/http/follower-counts.http.test.js | 84 +++++++ .../tests/unit/tooling/actor-follow.test.js | 1 + .../unit/tooling/follower-counts.test.js | 166 +++++++++++++ .../api/src/lexicons/app/certified/graph.ts | 1 + .../certified/graph/getFollowerCounts.defs.ts | 62 +++++ .../app/certified/graph/getFollowerCounts.ts | 6 + 23 files changed, 1053 insertions(+), 7 deletions(-) create mode 100644 .changeset/batch-follower-counts.md create mode 100644 docs/sources/lexicons/app.certified.graph.getFollowerCounts.json create mode 100644 happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json create mode 100644 happyview-modules/lua/endpoints/getFollowerCounts.lua create mode 100644 happyview-modules/lua/src/getFollowerCounts.lua create mode 100644 happyview-modules/modules/follower-counts/manifest.json create mode 100644 happyview-modules/tests/http/follower-counts.http.test.js create mode 100644 happyview-modules/tests/unit/tooling/follower-counts.test.js create mode 100644 packages/api/src/lexicons/app/certified/graph/getFollowerCounts.defs.ts create mode 100644 packages/api/src/lexicons/app/certified/graph/getFollowerCounts.ts diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 3d3e3919..8b398ae2 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -9,6 +9,7 @@ This task index lists the public query NSIDs in the bundled [OpenAPI contract](o - **Browse or filter without free text:** choose `list...` in the relevant domain. - **Text discovery:** use `search...` only where listed below. A search query is not interchangeable with a list filter, and search semantics vary by endpoint. - **Relationship checks:** use `get...Follow` for a single pair, or `getFollows` / `getEntityFollows` to check repeated target values for one actor. List queries answer who follows whom or which entities an actor follows. +- **Follower counts:** use `getFollowerCounts` for counts across many accounts and entities in one call; use a follower list's `totalCount` only when you also need that subject's followers. - **Feed reactions:** use `getLikes` or `getReposts` for distinct actors reacting to one subject URI, `getActorLikes` / `getActorReposts` for one actor's raw representative reactions, `getActorReactions` to check one actor across a bounded set of known subject URIs, or `getReactionCounts` for indexed like and repost counts across known subjects. Inspect the exact operation in the bundled OpenAPI contract before constructing a request: names such as `authors`, `actors`, `uri`, and `collection` have endpoint-specific meanings. Do not infer that a filter, search operation, output array name, or error exists just because a related endpoint has one. @@ -69,11 +70,12 @@ Choose `listCollectionItems` when the caller needs the referenced item versions, An `unanswered` award is a successful indexed award result, not a not-found response. See [the badge recipe](recipes.md#read-badge-award-response-status). -## Follow relationships (9) +## Follow relationships (10) | User task | Query NSID | Practical choice | |---|---|---| | Check one actor-to-actor follow | `app.certified.graph.getFollow` | Supply actor and subject DIDs; the representative `follow` may be null when none is indexed. | +| Count followers for many accounts and entities | `app.certified.graph.getFollowerCounts` | Supply 1–100 repeated `subjects` values, mixing account DIDs and entity AT-URIs freely. Results preserve input order and duplicates; each count equals that subject's `listActorFollowers` or `listEntityFollowers` `totalCount`. | | Check one actor against many subject DIDs | `app.certified.graph.getFollows` | Supply 1–100 repeated `subjects` values; each `{subject, follow}` result preserves input order and duplicates. | | List an actor's followers | `app.certified.graph.listActorFollowers` | `actor` is the followed actor DID. | | List actors an actor follows | `app.certified.graph.listActorFollowing` | `actor` is the follower DID. | diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index 2e5ca70e..6d9293ac 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -1459,6 +1459,56 @@ ] } }, + "/xrpc/app.certified.graph.getFollowerCounts": { + "get": { + "operationId": "app_certified_graph_getFollowerCounts", + "tags": ["app.certified.graph"], + "description": "Returns current indexed follower counts for a mixed list of accounts and entities. No authentication is required. A DID subject counts distinct publishers of app.certified.graph.follow records naming that DID; an AT-URI subject counts distinct publishers of app.certified.graph.entityFollow records naming that URI. Counts match listActorFollowers and listEntityFollowers totalCount, and do not require the subject to be indexed.", + "parameters": [ + { + "name": "subjects", + "in": "query", + "required": true, + "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "format": "uri", + "maxLength": 8192 + } + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app.certified.graph.getFollowerCounts.output" + } + } + } + } + }, + "x-lexicon-id": "app.certified.graph.getFollowerCounts", + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, exceeds the request-target limit, or contains a value that is neither a valid DID nor a full DID-authority AT-URI; correct the list and retry." + }, + { + "name": "FollowerCountQueryFailed", + "description": "The indexed follower count query failed; retry the request." + } + ] + } + }, "/xrpc/app.certified.graph.getFollows": { "get": { "operationId": "app_certified_graph_getFollows", @@ -4772,6 +4822,35 @@ } } }, + "app.certified.graph.getFollowerCounts.subjectFollowerCount": { + "type": "object", + "required": ["subject", "followerCount"], + "properties": { + "subject": { + "type": "string", + "description": "Subject DID or AT-URI exactly as requested by the caller.", + "format": "uri" + }, + "followerCount": { + "type": "integer", + "description": "Number of distinct indexed publisher DIDs with a current follow of this subject; zero when none are indexed.", + "minimum": 0 + } + } + }, + "app.certified.graph.getFollowerCounts.output": { + "type": "object", + "required": ["counts"], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated inputs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { + "$ref": "#/components/schemas/app.certified.graph.getFollowerCounts.subjectFollowerCount" + } + } + } + }, "app.certified.graph.getFollows.followResult": { "type": "object", "required": ["subject", "follow"], diff --git a/.agents/skills/hypercerts-api/references/request-patterns.md b/.agents/skills/hypercerts-api/references/request-patterns.md index b793d41d..c37ae526 100644 --- a/.agents/skills/hypercerts-api/references/request-patterns.md +++ b/.agents/skills/hypercerts-api/references/request-patterns.md @@ -39,6 +39,8 @@ A useful starting distinction: arrays often mean “any of these” for a filter The batch relationship checks `app.certified.graph.getFollows` and `app.certified.graph.getEntityFollows` each take one `actor` and 1–100 repeated, unbracketed `subjects` or `entities` values. Keep duplicates if the caller supplied them: each output `results` entry echoes its target and preserves its position. The encoded GET request-target must stay within 7,000 bytes. A null `follow` means only that no indexed relationship was found; database failures are errors, not nulls. +`app.certified.graph.getFollowerCounts` takes no `actor`: append 1–100 account DIDs and full DID-authority entity AT-URIs under the same unbracketed `subjects` key, in any mix. A value starting with `did:` is counted as an account; any other value must be an entity AT-URI. The response has one `{subject, followerCount}` entry per input occurrence in the same order, including duplicates. The same 7,000-byte request-target limit applies, and a zero count means no indexed followers were found at query time. + ### Batch an actor's reactions for known subjects `app.certified.feed.getActorReactions` requires one actor DID and 1–100 full record AT-URIs with DID authorities, not handle authorities. Encode `subjects` as repeated, unbracketed query parameters; keep input order and duplicates if each occurrence matters: diff --git a/.changeset/batch-follower-counts.md b/.changeset/batch-follower-counts.md new file mode 100644 index 00000000..81457f93 --- /dev/null +++ b/.changeset/batch-follower-counts.md @@ -0,0 +1,6 @@ +--- +'@hypercerts-org/happyview-modules': minor +'@hypercerts-org/api': minor +--- + +Add `app.certified.graph.getFollowerCounts`, which returns follower counts for up to 100 account DIDs and entity AT-URIs in one call. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df03bb5d..63e562aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or - Context attachment and evaluation retrieval with publisher/evaluator sidecar hydration, list filters, pagination across tied `createdAt` values, and named runtime errors. - Activity retrieval with contributor-sidecar hydration, author/organization/contributor/URI filters, tied timestamp pagination, and literal wildcard search. - Collection retrieval with CBOR-derived CIDs, location/tag projections, author, organization, item and tag filters, title/shortDescription search, `createdAt`/URI pagination ties, and source-order item pagination with exact-version resolution. -- Graph actor/entity lookups and lists, tied-key pagination, nullable profile/organization sidecars, entity target resolution, entity-follower totals across pages and empty pages, and the global recent-follows `before` filter. The `getFollows` and `getEntityFollows` batch queries also cover repeated target order and duplicates, explicit target keys, nullable raw views, and invalid-entry rejection. +- Graph actor/entity lookups and lists, tied-key pagination, nullable profile/organization sidecars, entity target resolution, entity-follower totals across pages and empty pages, and the global recent-follows `before` filter. The `getFollows` and `getEntityFollows` batch queries also cover repeated target order and duplicates, explicit target keys, nullable raw views, and invalid-entry rejection. The `getFollowerCounts` batch covers mixed DID and entity subjects, input order and duplicates, deduplicated publishers, zero counts, agreement with each list endpoint's `totalCount`, and invalid-subject rejection. - Feed-reaction subject and actor queries, earliest duplicate selection, URI-only subject matching across CIDs, distinct-actor totals, tied cursor pagination, page-only sidecar hydration, raw `via`/signature preservation, and actor queries without hydration. - Contribution exact-record retrieval, repeated publisher filters, tied ascending/descending cursor pagination, nullable publisher sidecars, and named errors. - Badge, badge-query, acknowledgement, contribution, and rights HTTP fixtures with CBOR-derived record CIDs; contribution DIDs are distinct from baseline fixture identities. diff --git a/docs/coverage.json b/docs/coverage.json index 5e596e43..22ae1eed 100644 --- a/docs/coverage.json +++ b/docs/coverage.json @@ -5,12 +5,12 @@ }, "runtimeValidation": "not-assessed", "deploymentValidation": "not-assessed", - "endpointCount": 63, + "endpointCount": 64, "methods": { - "GET": 63 + "GET": 64 }, "inclusion": { - "manifest-registered": 63 + "manifest-registered": 64 }, "unresolvedReferences": [], "endpoints": [ @@ -158,6 +158,12 @@ "inclusion": "manifest-registered", "module": "modules/actor-follow/manifest.json" }, + { + "id": "app.certified.graph.getFollowerCounts", + "method": "GET", + "inclusion": "manifest-registered", + "module": "modules/follower-counts/manifest.json" + }, { "id": "app.certified.graph.getFollows", "method": "GET", diff --git a/docs/openapi.json b/docs/openapi.json index ac206638..d8b6028d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1884,6 +1884,62 @@ ] } }, + "/xrpc/app.certified.graph.getFollowerCounts": { + "get": { + "operationId": "app_certified_graph_getFollowerCounts", + "tags": [ + "app.certified.graph" + ], + "description": "Returns current indexed follower counts for a mixed list of accounts and entities. No authentication is required. A DID subject counts distinct publishers of app.certified.graph.follow records naming that DID; an AT-URI subject counts distinct publishers of app.certified.graph.entityFollow records naming that URI. Counts match listActorFollowers and listEntityFollowers totalCount, and do not require the subject to be indexed.", + "parameters": [ + { + "name": "subjects", + "in": "query", + "required": true, + "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "format": "uri", + "maxLength": 8192 + } + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app.certified.graph.getFollowerCounts.output" + } + } + } + } + }, + "x-lexicon-id": "app.certified.graph.getFollowerCounts", + "x-hypercerts-coverage": "manifest-registered", + "x-hypercerts-source": { + "module": "modules/follower-counts/manifest.json" + }, + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, exceeds the request-target limit, or contains a value that is neither a valid DID nor a full DID-authority AT-URI; correct the list and retry." + }, + { + "name": "FollowerCountQueryFailed", + "description": "The indexed follower count query failed; retry the request." + } + ] + } + }, "/xrpc/app.certified.graph.getFollows": { "get": { "operationId": "app_certified_graph_getFollows", @@ -6368,6 +6424,40 @@ } } }, + "app.certified.graph.getFollowerCounts.subjectFollowerCount": { + "type": "object", + "required": [ + "subject", + "followerCount" + ], + "properties": { + "subject": { + "type": "string", + "description": "Subject DID or AT-URI exactly as requested by the caller.", + "format": "uri" + }, + "followerCount": { + "type": "integer", + "description": "Number of distinct indexed publisher DIDs with a current follow of this subject; zero when none are indexed.", + "minimum": 0 + } + } + }, + "app.certified.graph.getFollowerCounts.output": { + "type": "object", + "required": [ + "counts" + ], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated inputs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { + "$ref": "#/components/schemas/app.certified.graph.getFollowerCounts.subjectFollowerCount" + } + } + } + }, "app.certified.graph.getFollows.followResult": { "type": "object", "required": [ diff --git a/docs/sources/index.json b/docs/sources/index.json index e87fdaa3..dd82077c 100644 --- a/docs/sources/index.json +++ b/docs/sources/index.json @@ -221,6 +221,15 @@ "path": "../../lexicons/app.certified.graph.getFollow.json" } }, + { + "id": "app.certified.graph.getFollowerCounts", + "type": "query", + "module": "modules/follower-counts/manifest.json", + "file": "sources/lexicons/app.certified.graph.getFollowerCounts.json", + "source": { + "path": "../../lexicons/app.certified.graph.getFollowerCounts.json" + } + }, { "id": "app.certified.graph.getFollows", "type": "query", @@ -878,6 +887,15 @@ "path": "../../lexicons/app.certified.graph.getFollow.json" } }, + { + "id": "app.certified.graph.getFollowerCounts", + "file": "sources/lexicons/app.certified.graph.getFollowerCounts.json", + "kind": "endpoint", + "source": { + "module": "modules/follower-counts/manifest.json", + "path": "../../lexicons/app.certified.graph.getFollowerCounts.json" + } + }, { "id": "app.certified.graph.getFollows", "file": "sources/lexicons/app.certified.graph.getFollows.json", diff --git a/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json b/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json new file mode 100644 index 00000000..2eb2e09e --- /dev/null +++ b/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json @@ -0,0 +1,81 @@ +{ + "lexicon": 1, + "id": "app.certified.graph.getFollowerCounts", + "defs": { + "main": { + "type": "query", + "description": "Returns current indexed follower counts for a mixed list of accounts and entities. No authentication is required. A DID subject counts distinct publishers of app.certified.graph.follow records naming that DID; an AT-URI subject counts distinct publishers of app.certified.graph.entityFollow records naming that URI. Counts match listActorFollowers and listEntityFollowers totalCount, and do not require the subject to be indexed.", + "parameters": { + "type": "params", + "required": [ + "subjects" + ], + "properties": { + "subjects": { + "type": "array", + "minLength": 1, + "maxLength": 100, + "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "items": { + "type": "string", + "format": "uri", + "maxLength": 8192 + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, exceeds the request-target limit, or contains a value that is neither a valid DID nor a full DID-authority AT-URI; correct the list and retry." + }, + { + "name": "FollowerCountQueryFailed", + "description": "The indexed follower count query failed; retry the request." + } + ] + }, + "subjectFollowerCount": { + "type": "object", + "required": [ + "subject", + "followerCount" + ], + "properties": { + "subject": { + "type": "string", + "format": "uri", + "description": "Subject DID or AT-URI exactly as requested by the caller." + }, + "followerCount": { + "type": "integer", + "minimum": 0, + "description": "Number of distinct indexed publisher DIDs with a current follow of this subject; zero when none are indexed." + } + } + }, + "output": { + "type": "object", + "required": [ + "counts" + ], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated inputs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { + "type": "ref", + "ref": "#subjectFollowerCount" + } + } + } + } + } +} diff --git a/happyview-modules/README.md b/happyview-modules/README.md index 93ecae83..4583dd3d 100644 --- a/happyview-modules/README.md +++ b/happyview-modules/README.md @@ -10,6 +10,8 @@ Use the [`docs/` endpoint explorer](../docs/) and generated [OpenAPI reference]( `modules/actor-follow/manifest.json` provides `app.certified.graph.getFollows` for checking up to 100 subject DIDs against one actor. `modules/entity-follow/manifest.json` provides `app.certified.graph.getEntityFollows` for checking up to 100 entity AT-URIs against one actor. Supply targets as repeated query parameters; both queries preserve every input position and duplicate, return the original target beside its raw nullable representative, and reject the whole request when any target is invalid. The encoded GET request-target is bounded to 7,000 bytes. A null follow means no indexed relationship; database failures remain errors. +`modules/follower-counts/manifest.json` provides `app.certified.graph.getFollowerCounts`, which counts followers for 1–100 subjects in one statement. Subjects mix account DIDs, counted from `app.certified.graph.follow`, and entity AT-URIs, counted from `app.certified.graph.entityFollow`. Each count is the number of distinct indexed publishers, which equals the `totalCount` of `listActorFollowers` or `listEntityFollowers` for that subject. Results keep input order and duplicates, and unfollowed subjects count zero. + ## Feed reaction capability `modules/feed-reactions/manifest.json` owns the like and repost record schemas and six public queries: `app.certified.feed.getLikes`, `getReposts`, `getActorLikes`, `getActorReposts`, `getActorReactions`, 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. diff --git a/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json b/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json new file mode 100644 index 00000000..a866c710 --- /dev/null +++ b/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json @@ -0,0 +1,64 @@ +{ + "lexicon": 1, + "id": "app.certified.graph.getFollowerCounts", + "defs": { + "main": { + "type": "query", + "description": "Returns current indexed follower counts for a mixed list of accounts and entities. No authentication is required. A DID subject counts distinct publishers of app.certified.graph.follow records naming that DID; an AT-URI subject counts distinct publishers of app.certified.graph.entityFollow records naming that URI. Counts match listActorFollowers and listEntityFollowers totalCount, and do not require the subject to be indexed.", + "parameters": { + "type": "params", + "required": ["subjects"], + "properties": { + "subjects": { + "type": "array", + "minLength": 1, + "maxLength": 100, + "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "items": { "type": "string", "format": "uri", "maxLength": 8192 } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, exceeds the request-target limit, or contains a value that is neither a valid DID nor a full DID-authority AT-URI; correct the list and retry." + }, + { + "name": "FollowerCountQueryFailed", + "description": "The indexed follower count query failed; retry the request." + } + ] + }, + "subjectFollowerCount": { + "type": "object", + "required": ["subject", "followerCount"], + "properties": { + "subject": { + "type": "string", + "format": "uri", + "description": "Subject DID or AT-URI exactly as requested by the caller." + }, + "followerCount": { + "type": "integer", + "minimum": 0, + "description": "Number of distinct indexed publisher DIDs with a current follow of this subject; zero when none are indexed." + } + } + }, + "output": { + "type": "object", + "required": ["counts"], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated inputs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { "type": "ref", "ref": "#subjectFollowerCount" } + } + } + } + } +} diff --git a/happyview-modules/lua/endpoints/getFollowerCounts.lua b/happyview-modules/lua/endpoints/getFollowerCounts.lua new file mode 100644 index 00000000..61688088 --- /dev/null +++ b/happyview-modules/lua/endpoints/getFollowerCounts.lua @@ -0,0 +1,233 @@ +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 function entity_follow_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 _, segment in ipairs(segments) do + if #segment > 63 or segment:find("[^A-Za-z0-9%-]") + or segment:sub(1, 1) == "-" or segment:sub(-1) == "-" then + return false + end + end + + if not segments[1]:sub(1, 1):match("^[A-Za-z]$") then return false end + return segments[#segments]:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil +end + +-- An entity-follow target: a full DID-authority AT-URI with a valid collection NSID. +local function valid_entity_follow_uri(value) + if #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + return valid and entity_follow_valid_nsid(collection) +end + +local FOLLOW_BATCH_MAX_TARGETS = 100 +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 7000 + +local function encoded_query_value_length(value) + local length = 0 + for index = 1, #value do + local byte = value:byte(index) + if (byte >= 48 and byte <= 57) or (byte >= 65 and byte <= 90) or (byte >= 97 and byte <= 122) + or byte == 42 or byte == 45 or byte == 46 or byte == 95 then + length = length + 1 + else + length = length + 3 + end + end + return length +end + +local function follow_batch_targets(actor, key, value, validate, description, endpoint) + if value == nil then invalid(key .. " is required") end + + local targets = {} + if type(value) == "string" then + targets[1] = value + elseif type(value) == "table" then + local count, max_index = 0, 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must be a repeated string parameter") + end + count = count + 1 + if index > max_index then max_index = index end + end + if count ~= max_index then invalid(key .. " must be a contiguous array") end + if count < 1 or count > FOLLOW_BATCH_MAX_TARGETS then + invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") + end + for index = 1, max_index do + targets[index] = value[index] + end + else + invalid(key .. " must be a repeated string parameter") + end + + if #targets < 1 or #targets > FOLLOW_BATCH_MAX_TARGETS then + invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") + end + + -- actor is nil for batch queries that take only the target list; the first + -- target's "&" then stands in for the "?". + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + local unique, seen = {}, {} + for _, target in ipairs(targets) do + if type(target) ~= "string" or not validate(target) then + invalid("each " .. key .. " value must be " .. description) + end + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + if not seen[target] then + seen[target] = true + unique[#unique + 1] = target + end + end + + if request_target_length > FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") + end + return targets, unique +end + +local FOLLOW = "app.certified.graph.follow" +local ENTITY_FOLLOW = "app.certified.graph.entityFollow" + +-- DIDs are account follows; anything else must be an entity-follow AT-URI. +local function valid_follower_count_subject(value) + if value:sub(1, 4) == "did:" then return valid_did(value) end + return valid_entity_follow_uri(value) +end + +local function follower_count_placeholders(values, subjects) + local placeholders = {} + for _, subject in ipairs(subjects) do + values[#values + 1] = subject + placeholders[#placeholders + 1] = "$" .. #values + end + return table.concat(placeholders, ", ") +end + +-- One statement for both collections. Each branch counts distinct publishers per +-- subject, which equals the deduplicated relationship totalCount of +-- listActorFollowers and listEntityFollowers. +local function follower_count_query(dids, entity_uris) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("FollowerCountQueryFailed: follower counts require PostgreSQL", 0) + end + + local values, branches = {}, {} + if #dids > 0 then + values[#values + 1] = FOLLOW + local collection = "$" .. #values + local subject = "record::jsonb->>'subject'" + branches[#branches + 1] = "SELECT " .. subject .. " AS subject, COUNT(DISTINCT did) AS follower_count " .. + "FROM happyview_records WHERE collection = " .. collection .. " AND " .. subject .. + " IN (" .. follower_count_placeholders(values, dids) .. ") GROUP BY " .. subject + end + if #entity_uris > 0 then + values[#values + 1] = ENTITY_FOLLOW + local collection = "$" .. #values + local subject = "record::jsonb->'subject'->>'uri'" + branches[#branches + 1] = "SELECT " .. subject .. " AS subject, COUNT(DISTINCT did) AS follower_count " .. + "FROM happyview_records WHERE collection = " .. collection .. " AND " .. subject .. + " IN (" .. follower_count_placeholders(values, entity_uris) .. ") GROUP BY " .. subject + end + + local ok, rows = pcall(db.raw, table.concat(branches, " UNION ALL "), values) + if not ok or type(rows) ~= "table" then + error("FollowerCountQueryFailed: indexed follower count query failed", 0) + end + return rows +end + +local function follower_count_number(value) + local count = tonumber(value) + if not count or count < 0 or count % 1 ~= 0 then + error("FollowerCountQueryFailed: indexed follower count was unavailable", 0) + end + return count +end + +local function get_follower_counts() + keys_only(params, { subjects = true }) + -- A single subject arrives as a plain value; read it like getReactionCounts does. + local subjects_param = params.subjects + if type(subjects_param) == "string" then subjects_param = scalar(params, "subjects") end + local subjects, unique_subjects = follow_batch_targets( + nil, "subjects", subjects_param, valid_follower_count_subject, + "a valid DID or a full AT-URI with a DID authority and valid collection NSID", + "app.certified.graph.getFollowerCounts") + + local dids, entity_uris = {}, {} + for _, subject in ipairs(unique_subjects) do + if subject:sub(1, 4) == "did:" then + dids[#dids + 1] = subject + else + entity_uris[#entity_uris + 1] = subject + end + end + + local counts_by_subject = {} + for _, row in ipairs(follower_count_query(dids, entity_uris)) do + if type(row) ~= "table" or type(row.subject) ~= "string" then + error("FollowerCountQueryFailed: indexed follower count subject was unavailable", 0) + end + counts_by_subject[row.subject] = follower_count_number(row.follower_count) + end + + local counts = {} + for _, subject in ipairs(subjects) do + counts[#counts + 1] = { subject = subject, followerCount = counts_by_subject[subject] or 0 } + end + return { counts = toarray(counts) } +end + +function handle() + return get_follower_counts() +end diff --git a/happyview-modules/lua/endpoints/getFollows.lua b/happyview-modules/lua/endpoints/getFollows.lua index ed89a8e5..9467185c 100644 --- a/happyview-modules/lua/endpoints/getFollows.lua +++ b/happyview-modules/lua/endpoints/getFollows.lua @@ -102,7 +102,12 @@ local function follow_batch_targets(actor, key, value, validate, description, en invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") end - local request_target_length = #("/xrpc/" .. endpoint .. "?actor=") + encoded_query_value_length(actor) + -- actor is nil for batch queries that take only the target list; the first + -- target's "&" then stands in for the "?". + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end local unique, seen = {}, {} for _, target in ipairs(targets) do if type(target) ~= "string" or not validate(target) then diff --git a/happyview-modules/lua/shared/followBatch.lua b/happyview-modules/lua/shared/followBatch.lua index a888503c..dd15088e 100644 --- a/happyview-modules/lua/shared/followBatch.lua +++ b/happyview-modules/lua/shared/followBatch.lua @@ -45,7 +45,12 @@ local function follow_batch_targets(actor, key, value, validate, description, en invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") end - local request_target_length = #("/xrpc/" .. endpoint .. "?actor=") + encoded_query_value_length(actor) + -- actor is nil for batch queries that take only the target list; the first + -- target's "&" then stands in for the "?". + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end local unique, seen = {}, {} for _, target in ipairs(targets) do if type(target) ~= "string" or not validate(target) then diff --git a/happyview-modules/lua/src/getFollowerCounts.lua b/happyview-modules/lua/src/getFollowerCounts.lua new file mode 100644 index 00000000..c041ea6a --- /dev/null +++ b/happyview-modules/lua/src/getFollowerCounts.lua @@ -0,0 +1,97 @@ +local FOLLOW = "app.certified.graph.follow" +local ENTITY_FOLLOW = "app.certified.graph.entityFollow" + +-- DIDs are account follows; anything else must be an entity-follow AT-URI. +local function valid_follower_count_subject(value) + if value:sub(1, 4) == "did:" then return valid_did(value) end + return valid_entity_follow_uri(value) +end + +local function follower_count_placeholders(values, subjects) + local placeholders = {} + for _, subject in ipairs(subjects) do + values[#values + 1] = subject + placeholders[#placeholders + 1] = "$" .. #values + end + return table.concat(placeholders, ", ") +end + +-- One statement for both collections. Each branch counts distinct publishers per +-- subject, which equals the deduplicated relationship totalCount of +-- listActorFollowers and listEntityFollowers. +local function follower_count_query(dids, entity_uris) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("FollowerCountQueryFailed: follower counts require PostgreSQL", 0) + end + + local values, branches = {}, {} + if #dids > 0 then + values[#values + 1] = FOLLOW + local collection = "$" .. #values + local subject = "record::jsonb->>'subject'" + branches[#branches + 1] = "SELECT " .. subject .. " AS subject, COUNT(DISTINCT did) AS follower_count " .. + "FROM happyview_records WHERE collection = " .. collection .. " AND " .. subject .. + " IN (" .. follower_count_placeholders(values, dids) .. ") GROUP BY " .. subject + end + if #entity_uris > 0 then + values[#values + 1] = ENTITY_FOLLOW + local collection = "$" .. #values + local subject = "record::jsonb->'subject'->>'uri'" + branches[#branches + 1] = "SELECT " .. subject .. " AS subject, COUNT(DISTINCT did) AS follower_count " .. + "FROM happyview_records WHERE collection = " .. collection .. " AND " .. subject .. + " IN (" .. follower_count_placeholders(values, entity_uris) .. ") GROUP BY " .. subject + end + + local ok, rows = pcall(db.raw, table.concat(branches, " UNION ALL "), values) + if not ok or type(rows) ~= "table" then + error("FollowerCountQueryFailed: indexed follower count query failed", 0) + end + return rows +end + +local function follower_count_number(value) + local count = tonumber(value) + if not count or count < 0 or count % 1 ~= 0 then + error("FollowerCountQueryFailed: indexed follower count was unavailable", 0) + end + return count +end + +local function get_follower_counts() + keys_only(params, { subjects = true }) + -- A single subject arrives as a plain value; read it like getReactionCounts does. + local subjects_param = params.subjects + if type(subjects_param) == "string" then subjects_param = scalar(params, "subjects") end + local subjects, unique_subjects = follow_batch_targets( + nil, "subjects", subjects_param, valid_follower_count_subject, + "a valid DID or a full AT-URI with a DID authority and valid collection NSID", + "app.certified.graph.getFollowerCounts") + + local dids, entity_uris = {}, {} + for _, subject in ipairs(unique_subjects) do + if subject:sub(1, 4) == "did:" then + dids[#dids + 1] = subject + else + entity_uris[#entity_uris + 1] = subject + end + end + + local counts_by_subject = {} + for _, row in ipairs(follower_count_query(dids, entity_uris)) do + if type(row) ~= "table" or type(row.subject) ~= "string" then + error("FollowerCountQueryFailed: indexed follower count subject was unavailable", 0) + end + counts_by_subject[row.subject] = follower_count_number(row.follower_count) + end + + local counts = {} + for _, subject in ipairs(subjects) do + counts[#counts + 1] = { subject = subject, followerCount = counts_by_subject[subject] or 0 } + end + return { counts = toarray(counts) } +end + +function handle() + return get_follower_counts() +end diff --git a/happyview-modules/manifest.json b/happyview-modules/manifest.json index 6b0d856d..329104cf 100644 --- a/happyview-modules/manifest.json +++ b/happyview-modules/manifest.json @@ -54,6 +54,10 @@ "id": "app.certified.graph.listRecentFollows", "path": "lexicons/app.certified.graph.listRecentFollows.json" }, + { + "id": "app.certified.graph.getFollowerCounts", + "path": "lexicons/app.certified.graph.getFollowerCounts.json" + }, { "id": "app.certified.feed.getLikes", "path": "lexicons/app.certified.feed.getLikes.json" @@ -494,6 +498,7 @@ "modules/actor-follow/manifest.json", "modules/entity-follow/manifest.json", "modules/recent-follows/manifest.json", + "modules/follower-counts/manifest.json", "modules/feed-reactions/manifest.json", "modules/context-evaluation/manifest.json", "modules/context-attachments/manifest.json", diff --git a/happyview-modules/modules/follower-counts/manifest.json b/happyview-modules/modules/follower-counts/manifest.json new file mode 100644 index 00000000..49ed7e46 --- /dev/null +++ b/happyview-modules/modules/follower-counts/manifest.json @@ -0,0 +1,31 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "app.certified.graph.getFollowerCounts", + "path": "../../lexicons/app.certified.graph.getFollowerCounts.json", + "config": { + "backfill": false + }, + "dependsOn": ["app.certified.graph.follow", "app.certified.graph.entityFollow"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.graph.getFollowerCounts", + "path": "../../lua/endpoints/getFollowerCounts.lua", + "sourcePath": "../../lua/src/getFollowerCounts.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/entityFollowTarget.lua", + "../../lua/shared/followBatch.lua" + ], + "config": { + "script_type": "lua", + "description": "Batched account and entity follower count query handler" + }, + "dependsOn": ["app.certified.graph.getFollowerCounts"] + } + ] +} diff --git a/happyview-modules/tests/http/follower-counts.http.test.js b/happyview-modules/tests/http/follower-counts.http.test.js new file mode 100644 index 00000000..7ade1a66 --- /dev/null +++ b/happyview-modules/tests/http/follower-counts.http.test.js @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { graphDids, graphUris } from "./fixtures/graph-follows.fixture.js"; +import { contractUrl, requireContractTarget } from "./helpers.js"; + +const baseUrl = requireContractTarget(); +const endpoint = "app.certified.graph.getFollowerCounts"; +const unfollowedDid = "did:plc:999999999999999999999999"; +const unfollowedEntity = `at://${graphDids.entityAuthor}/org.hypercerts.entity.feature/3jzfcijpj2z2z`; + +async function request(nsid, params) { + const response = await fetch(contractUrl(baseUrl, nsid, params), { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { response, body }; +} + +async function listTotal(nsid, params) { + const { response, body } = await request(nsid, { ...params, limit: 1 }); + assert.equal(response.status, 200, JSON.stringify(body)); + return body.totalCount; +} + +test("getFollowerCounts batches DIDs and entity URIs and matches the list endpoints' totalCount", async () => { + const subjects = [ + graphDids.primarySubject, + graphUris.followedFeature, + unfollowedDid, + graphDids.secondSubject, + graphUris.unresolvedFeature, + graphDids.primarySubject, + unfollowedEntity, + ]; + const { response, body } = await request(endpoint, { subjects }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual( + body.counts.map(({ subject }) => subject), + subjects, + "one entry per requested occurrence, in request order", + ); + + const counts = body.counts.map(({ followerCount }) => followerCount); + // Duplicate follows by one publisher count once: primarySubject and followedFeature + // each have four follow records from three publishers. + assert.deepEqual(counts, [3, 3, 0, 1, 1, 3, 0]); + + assert.equal( + counts[0], + await listTotal("app.certified.graph.listActorFollowers", { actor: graphDids.primarySubject }), + ); + assert.equal( + counts[1], + await listTotal("app.certified.graph.listEntityFollowers", { + entity: graphUris.followedFeature, + }), + ); + assert.equal( + counts[3], + await listTotal("app.certified.graph.listActorFollowers", { actor: graphDids.secondSubject }), + ); + assert.equal( + counts[4], + await listTotal("app.certified.graph.listEntityFollowers", { + entity: graphUris.unresolvedFeature, + }), + ); +}); + +test("getFollowerCounts rejects a subject that is neither a DID nor a full AT-URI", async () => { + const { response, body } = await request(endpoint, { + subjects: [graphDids.primarySubject, "alice.example"], + }); + assert.equal(response.status, 500, JSON.stringify(body)); + assert.equal(body.error, "script_error"); + assert.match(body.message, /InvalidRequest/); +}); diff --git a/happyview-modules/tests/unit/tooling/actor-follow.test.js b/happyview-modules/tests/unit/tooling/actor-follow.test.js index d402ca68..3ee06314 100644 --- a/happyview-modules/tests/unit/tooling/actor-follow.test.js +++ b/happyview-modules/tests/unit/tooling/actor-follow.test.js @@ -197,6 +197,7 @@ test("actor-follow module installs the follow record schema, three queries, and "xrpc.query:app.certified.graph.listEntityFollowers", "xrpc.query:app.certified.graph.listEntityFollowing", "xrpc.query:app.certified.graph.listRecentFollows", + "xrpc.query:app.certified.graph.getFollowerCounts", ].sort(), ); }); diff --git a/happyview-modules/tests/unit/tooling/follower-counts.test.js b/happyview-modules/tests/unit/tooling/follower-counts.test.js new file mode 100644 index 00000000..12bd59eb --- /dev/null +++ b/happyview-modules/tests/unit/tooling/follower-counts.test.js @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { validatePackageLexicons } from "../../../tooling/validate-lexicons.js"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const endpointPath = "lua/endpoints/getFollowerCounts.lua"; +const follow = "app.certified.graph.follow"; +const entityFollow = "app.certified.graph.entityFollow"; +const account = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; +const otherAccount = "did:web:example.com"; +const entity = "at://did:plc:bbbbbbbbbbbbbbbbbbbbbbbb/org.hypercerts.collection/project"; +const missingEntity = "at://did:plc:cccccccccccccccccccccccc/org.hypercerts.claim.activity/missing"; + +function lua(value) { + if (value === null) return "nil"; + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return `{${value.map(lua).join(",")}}`; + if (value && typeof value === "object") { + return `{${Object.entries(value) + .map(([key, item]) => `[${JSON.stringify(key)}]=${lua(item)}`) + .join(",")}}`; + } + throw new TypeError(`Cannot encode ${typeof value} as a Lua fixture`); +} + +function runLua({ + params, + rows = [], + failure = false, + backend = "postgres", + expectError, + expectedCalls, + assertions, +}) { + assert.ok(existsSync(path.join(root, endpointPath)), `${endpointPath} must be generated first`); + const source = ` +local rows = ${lua(rows)} +local calls = {} +toarray = function(value) return value end +json = { decode = function() error('unexpected JSON decode') end } +params = ${lua(params)} +db = { + backend = function() return '${backend}' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + if ${failure} then error('injected follower count failure') end + return rows + end, +} +dofile('${endpointPath}') +local ok, result = pcall(handle) +${ + expectError + ? `assert(not ok, 'expected handler to reject the request') +assert(tostring(result):find('${expectError}', 1, true), tostring(result)) +assert(#calls == ${expectedCalls ?? 0}, 'unexpected PostgreSQL query count: ' .. #calls)` + : `assert(ok, tostring(result))\n${assertions ?? ""}` +} +`; + const result = spawnSync("lua5.4", ["-e", source], { cwd: root, encoding: "utf8" }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +} + +test("getFollowerCounts counts mixed DIDs and entity URIs in one statement, in request order", () => { + runLua({ + params: { subjects: [account, entity, missingEntity, account, otherAccount] }, + rows: [ + { subject: account, follower_count: "3" }, + { subject: entity, follower_count: "2" }, + ], + assertions: ` +assert(#calls == 1, 'one statement must serve every subject') +local values = calls[1].values +assert(values[1] == '${follow}' and values[2] == '${account}' and values[3] == '${otherAccount}') +assert(values[4] == '${entityFollow}' and values[5] == '${entity}' and values[6] == '${missingEntity}') +assert(#values == 6, 'duplicate subjects are queried once') +assert(#result.counts == 5) +local expected = { + { '${account}', 3 }, { '${entity}', 2 }, { '${missingEntity}', 0 }, { '${account}', 3 }, { '${otherAccount}', 0 }, +} +for index, pair in ipairs(expected) do + assert(result.counts[index].subject == pair[1] and result.counts[index].followerCount == pair[2], 'entry ' .. index) + assert(math.type(result.counts[index].followerCount) == 'integer') +end +`, + }); +}); + +test("getFollowerCounts queries only the collection a single-kind batch needs", () => { + runLua({ + params: { subjects: [account] }, + assertions: ` +assert(#calls == 1 and not calls[1].sql:find('UNION', 1, true)) +assert(calls[1].values[1] == '${follow}' and #calls[1].values == 2) +assert(result.counts[1].followerCount == 0) +`, + }); + runLua({ + params: { subjects: entity }, + assertions: ` +assert(#calls == 1 and not calls[1].sql:find('UNION', 1, true)) +assert(calls[1].values[1] == '${entityFollow}' and calls[1].values[2] == '${entity}') +`, + }); +}); + +test("getFollowerCounts accepts 100 subjects and rejects invalid batches before querying", () => { + runLua({ + params: { subjects: Array(100).fill(account) }, + assertions: "assert(#result.counts == 100 and #calls == 1)", + }); + + const longEntity = `at://did:plc:dddddddddddddddddddddddd/org.example.thing/${"k".repeat(500)}`; + // Batch-shape errors (missing, empty, over 100, unknown keys) are covered by the + // shared follow_batch_targets tests; these cases are specific to subject classification. + for (const params of [ + { subjects: ["alice.example"] }, + { subjects: ["did:plc:"] }, + { subjects: ["at://alice.example/org.hypercerts.collection/project"] }, + { subjects: ["at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/invalid/key"] }, + { subjects: [`${entity}?query`] }, + { subjects: Array(15).fill(longEntity) }, + ]) { + runLua({ params, expectError: "InvalidRequest:", expectedCalls: 0 }); + } +}); + +test("getFollowerCounts reports named errors for database and count failures", () => { + runLua({ + params: { subjects: [account] }, + failure: true, + expectError: "FollowerCountQueryFailed:", + expectedCalls: 1, + }); + runLua({ + params: { subjects: [account] }, + backend: "sqlite", + expectError: "FollowerCountQueryFailed:", + expectedCalls: 0, + }); + for (const row of [ + { subject: account, follower_count: "-1" }, + { subject: account, follower_count: "1.5" }, + { follower_count: "1" }, + ]) { + runLua({ + params: { subjects: [account] }, + rows: [row], + expectError: "FollowerCountQueryFailed:", + expectedCalls: 1, + }); + } +}); + +test("getFollowerCounts Lexicon accepts DIDs and AT-URIs in one subjects list", async () => { + const { lexicons } = await validatePackageLexicons(); + const main = lexicons.getDefOrThrow("app.certified.graph.getFollowerCounts"); + // DIDs and AT-URIs share one list, so items use the generic uri format. + assert.equal(main.parameters.properties.subjects.items.format, "uri"); + assert.equal(main.parameters.properties.subjects.maxLength, 100); +}); diff --git a/packages/api/src/lexicons/app/certified/graph.ts b/packages/api/src/lexicons/app/certified/graph.ts index 649f9c6c..b384794f 100644 --- a/packages/api/src/lexicons/app/certified/graph.ts +++ b/packages/api/src/lexicons/app/certified/graph.ts @@ -7,6 +7,7 @@ export * as follow from "@hypercerts-org/lexicon/app/certified/graph/follow"; export * as getEntityFollow from "./graph/getEntityFollow.js"; export * as getEntityFollows from "./graph/getEntityFollows.js"; export * as getFollow from "./graph/getFollow.js"; +export * as getFollowerCounts from "./graph/getFollowerCounts.js"; export * as getFollows from "./graph/getFollows.js"; export * as listActorFollowers from "./graph/listActorFollowers.js"; export * as listActorFollowing from "./graph/listActorFollowing.js"; diff --git a/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.defs.ts b/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.defs.ts new file mode 100644 index 00000000..9e5aeba1 --- /dev/null +++ b/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.defs.ts @@ -0,0 +1,62 @@ +/* + * THIS FILE WAS GENERATED BY scripts/generate.js WITH "@atproto/lex". DO NOT EDIT. + */ + +import { l } from "@atproto/lex"; + +const $nsid = "app.certified.graph.getFollowerCounts"; + +type $nsid = typeof $nsid; + +export { $nsid }; + +export const $params = /*#__PURE__*/ l.params({"subjects":/*#__PURE__*/ l.array(/*#__PURE__*/ l.string({"format":"uri","maxLength":8192}), {"minLength":1,"maxLength":100})}); + +export type $Params = l.InferOutput; + +export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ l.ref((() => output) as any)); + +export type $Output = l.InferPayload; +export type $OutputBody = l.InferPayloadBody; + +/** Returns current indexed follower counts for a mixed list of accounts and entities. No authentication is required. A DID subject counts distinct publishers of app.certified.graph.follow records naming that DID; an AT-URI subject counts distinct publishers of app.certified.graph.entityFollow records naming that URI. Counts match listActorFollowers and listEntityFollowers totalCount, and do not require the subject to be indexed. */ +const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","FollowerCountQueryFailed"]); + +export { main }; + +const $lxm = $nsid; + +type $lxm = typeof $lxm; + +export { $lxm }; + +type SubjectFollowerCount = { $type?: "app.certified.graph.getFollowerCounts#subjectFollowerCount"; + + /** + * Subject DID or AT-URI exactly as requested by the caller. + */ + "subject":l.UriString; + + /** + * Number of distinct indexed publisher DIDs with a current follow of this subject; zero when none are indexed. + */ + "followerCount":number }; + +export type { SubjectFollowerCount }; + +const subjectFollowerCount = /*#__PURE__*/ l.typedObject($nsid, "subjectFollowerCount", /*#__PURE__*/ l.object({"subject":/*#__PURE__*/ l.string({"format":"uri"}),"followerCount":/*#__PURE__*/ l.integer({"minimum":0})})); + +export { subjectFollowerCount }; + +type Output = { $type?: "app.certified.graph.getFollowerCounts#output"; + + /** + * One entry for each requested subject, in request order. Repeated inputs produce repeated entries. Counts reflect current indexed state and may change between requests. + */ + "counts":(SubjectFollowerCount)[] }; + +export type { Output }; + +const output = /*#__PURE__*/ l.typedObject($nsid, "output", /*#__PURE__*/ l.object({"counts":/*#__PURE__*/ l.array(/*#__PURE__*/ l.ref((() => subjectFollowerCount) as any), )})); + +export { output }; diff --git a/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.ts b/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.ts new file mode 100644 index 00000000..c0a997ec --- /dev/null +++ b/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.ts @@ -0,0 +1,6 @@ +/* + * THIS FILE WAS GENERATED BY scripts/generate.js WITH "@atproto/lex". DO NOT EDIT. + */ + +export * from "./getFollowerCounts.defs.js"; +export { main as default } from "./getFollowerCounts.defs.js";