From 79a9de49769365853a07560c5c7a4932deba73c0 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 14 Oct 2026 13:47:10 +0600 Subject: [PATCH] graph: add totalCount to entity followers --- .../hypercerts-api/references/endpoint-map.md | 2 +- .../hypercerts-api/references/openapi.json | 4 +- .changeset/entity-follower-total-count.md | 5 ++ CONTRIBUTING.md | 2 +- ...p.certified.graph.listEntityFollowers.json | 5 +- api/lua/endpoints/listEntityFollowers.lua | 29 +++++-- api/lua/endpoints/listEntityFollowing.lua | 25 ++++-- api/lua/shared/entityFollowPagination.lua | 25 ++++-- api/lua/src/listEntityFollowers.lua | 4 +- api/tests/http/entity-follow.http.test.js | 18 ++++ api/tests/unit/tooling/entity-follow.test.js | 87 ++++++++++++++++--- docs/openapi.json | 10 ++- ...p.certified.graph.listEntityFollowers.json | 10 ++- 13 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 .changeset/entity-follower-total-count.md diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index de40fe2..1f68d29 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -77,7 +77,7 @@ An `unanswered` award is a successful indexed award result, not a not-found resp | 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. | | Check one actor-to-entity follow | `app.certified.graph.getEntityFollow` | Supply actor DID and entity AT-URI; check its nullable/error contract. | -| List actors following an entity | `app.certified.graph.listEntityFollowers` | `entity` is an entity AT-URI. | +| List actors following an entity | `app.certified.graph.listEntityFollowers` | `entity` is an entity AT-URI; `totalCount` reports distinct relationships before cursor and limit, including on empty pages. | | List entities an actor follows | `app.certified.graph.listEntityFollowing` | `actor` is an actor DID; targets are hydrated from indexed records. | | Browse recent raw follow records | `app.certified.graph.listRecentFollows` | Beta feed across publishers; `before` and pagination have distinct feed semantics. Not a per-actor relationship check. | diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index 8b4da9d..d97df56 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -27,7 +27,7 @@ "/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."}]}}, "/xrpc/app.certified.graph.listActorFollowing": {"get":{"operationId":"app_certified_graph_listActorFollowing","tags":["app.certified.graph"],"description":"Lists actors named as subjects by the requested DID's follow records. 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 outgoing 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.listActorFollowing.output"}}}}},"x-lexicon-id":"app.certified.graph.listActorFollowing","x-lexicon-errors":[{"name":"InvalidRequest","description":"A DID or query parameter is invalid."}]}}, - "/xrpc/app.certified.graph.listEntityFollowers": {"get":{"operationId":"app_certified_graph_listEntityFollowers","tags":["app.certified.graph"],"description":"Lists actors following an entity AT-URI. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated: missing profile and organization sidecars are null, while lookup failures return errors rather than partial pages.","parameters":[{"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"}},{"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.listEntityFollowers.output"}}}}},"x-lexicon-id":"app.certified.graph.listEntityFollowers","x-lexicon-errors":[{"name":"InvalidRequest","description":"The entity AT-URI or another query parameter is invalid."}]}}, + "/xrpc/app.certified.graph.listEntityFollowers": {"get":{"operationId":"app_certified_graph_listEntityFollowers","tags":["app.certified.graph"],"description":"Lists actors following an entity AT-URI. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. totalCount reports distinct relationships before cursor and limit. Only the returned page is hydrated: missing profile and organization sidecars are null, while lookup failures return errors rather than partial pages.","parameters":[{"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"}},{"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.listEntityFollowers.output"}}}}},"x-lexicon-id":"app.certified.graph.listEntityFollowers","x-lexicon-errors":[{"name":"InvalidRequest","description":"The entity AT-URI or another query parameter is invalid."}]}}, "/xrpc/app.certified.graph.listEntityFollowing": {"get":{"operationId":"app_certified_graph_listEntityFollowing","tags":["app.certified.graph"],"description":"Lists entities followed by an actor DID. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. Only returned-page targets are hydrated and resolved to the latest indexed record by URI; missing or unsupported targets retain their URI and follow with entity null, while lookup failures return errors rather than partial pages.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID whose outgoing entity 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.listEntityFollowing.output"}}}}},"x-lexicon-id":"app.certified.graph.listEntityFollowing","x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID or another query parameter is invalid."}]}}, "/xrpc/app.certified.graph.listRecentFollows": {"get":{"operationId":"app_certified_graph_listRecentFollows","tags":["app.certified.graph"],"description":"Beta query for raw account- and entity-follow records from any publisher. No authentication is required; duplicates are preserved, and before is an exclusive bound on publisher-declared createdAt. Pages are not a snapshot, so late indexing or concurrent changes may cause records to be missed.","parameters":[{"name":"before","in":"query","required":false,"description":"Exclusive upper bound on publisher-declared createdAt, not indexedAt; omit to start with the newest records.","schema":{"type":"string","format":"datetime"}},{"name":"limit","in":"query","required":false,"description":"Maximum records per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor; keep before unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.graph.listRecentFollows.output"}}}}},"x-lexicon-id":"app.certified.graph.listRecentFollows","x-lexicon-errors":[{"name":"InvalidRequest","description":"A datetime, limit, cursor, or query parameter is invalid."}]}}, "/xrpc/app.certified.link.getEvmLink": {"get":{"operationId":"app_certified_link_getEvmLink","tags":["app.certified.link"],"description":"Looks up an indexed EVM-link record by exact AT-URI and hydrates its actor. Authentication is not required.","parameters":[{"name":"uri","in":"query","required":true,"description":"Full AT-URI of an app.certified.link.evm record with a DID authority.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.link.getEvmLink.output"}}}}},"x-lexicon-id":"app.certified.link.getEvmLink","x-lexicon-errors":[{"name":"InvalidRequest","description":"The URI is invalid or is not an app.certified.link.evm record AT-URI."},{"name":"RecordNotFound","description":"No indexed EVM-link record exists at this AT-URI."}]}}, @@ -118,7 +118,7 @@ "app.certified.graph.listActorFollowers.output": {"type":"object","required":["followers","totalCount"],"properties":{"followers":{"type":"array","description":"Incoming relationships; each displayed DID identifies the actor who published the follow.","items":{"$ref":"#/components/schemas/app.certified.graph.getFollow.actorFollowView"}},"totalCount":{"type":"integer","description":"Count of deduplicated relationships before cursor and limit are applied.","minimum":0},"cursor":{"type":"string","description":"Next-page cursor; omitted when no more results are available."}}}, "app.certified.graph.listActorFollowing.output": {"type":"object","required":["following","totalCount"],"properties":{"following":{"type":"array","description":"Outgoing relationships; each displayed DID identifies the actor named as the follow subject.","items":{"$ref":"#/components/schemas/app.certified.graph.getFollow.actorFollowView"}},"totalCount":{"type":"integer","description":"Count of deduplicated relationships before cursor and limit are applied.","minimum":0},"cursor":{"type":"string","description":"Next-page cursor; omitted when no more results are available."}}}, "app.certified.graph.listEntityFollowers.entityFollowerView": {"type":"object","description":"The follower actor and the raw entity-follow record representing its relationship.","required":["did","profile","organization","follow"],"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"}]},"follow":{"$ref":"#/components/schemas/app.certified.graph.getEntityFollow.entityFollowRecordView"}}}, - "app.certified.graph.listEntityFollowers.output": {"type":"object","required":["followers"],"properties":{"followers":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.graph.listEntityFollowers.entityFollowerView"}},"cursor":{"type":"string","description":"Next-page cursor; omitted when no more results are available."}}}, + "app.certified.graph.listEntityFollowers.output": {"type":"object","required":["followers","totalCount"],"properties":{"followers":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.graph.listEntityFollowers.entityFollowerView"}},"totalCount":{"type":"integer","description":"Count of distinct actor–entity relationships after duplicate records are collapsed, before cursor and limit are applied.","minimum":0},"cursor":{"type":"string","description":"Next-page cursor; omitted when no more results are available."}}}, "org.hypercerts.claim.activity.main": {"type":"record","description":"A hypercert record tracking impact work."}, "org.hypercerts.claim.activity.contributorIdentity": {"type":"object","description":"Contributor information as a string (DID or identifier).","required":["identity"],"properties":{"identity":{"type":"string","description":"The contributor identity string (DID or identifier).","maxLength":1000,"x-lexicon-maxGraphemes":100}}}, "com.atproto.repo.strongRef.main": {"type":"object","required":["uri","cid"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"}}}, diff --git a/.changeset/entity-follower-total-count.md b/.changeset/entity-follower-total-count.md new file mode 100644 index 0000000..0673865 --- /dev/null +++ b/.changeset/entity-follower-total-count.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': minor +--- + +Entity follower results now include `totalCount`, the number of distinct actor–entity relationships before pagination. The count is also returned when a page has no followers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b05d7ab..013db81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,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, and the global recent-follows `before` filter. +- 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. - 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/api/lexicons/app.certified.graph.listEntityFollowers.json b/api/lexicons/app.certified.graph.listEntityFollowers.json index 380a10c..e3ca326 100644 --- a/api/lexicons/app.certified.graph.listEntityFollowers.json +++ b/api/lexicons/app.certified.graph.listEntityFollowers.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Lists actors following an entity AT-URI. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated: missing profile and organization sidecars are null, while lookup failures return errors rather than partial pages.", + "description": "Lists actors following an entity AT-URI. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. totalCount reports distinct relationships before cursor and limit. Only the returned page is hydrated: missing profile and organization sidecars are null, while lookup failures return errors rather than partial pages.", "parameters": { "type": "params", "required": ["entity"], @@ -32,9 +32,10 @@ }, "output": { "type": "object", - "required": ["followers"], + "required": ["followers", "totalCount"], "properties": { "followers": { "type": "array", "items": { "type": "ref", "ref": "#entityFollowerView" } }, + "totalCount": { "type": "integer", "minimum": 0, "description": "Count of distinct actor–entity relationships after duplicate records are collapsed, before cursor and limit are applied." }, "cursor": { "type": "string", "description": "Next-page cursor; omitted when no more results are available." } } } diff --git a/api/lua/endpoints/listEntityFollowers.lua b/api/lua/endpoints/listEntityFollowers.lua index 74089e2..f0e5223 100644 --- a/api/lua/endpoints/listEntityFollowers.lua +++ b/api/lua/endpoints/listEntityFollowers.lua @@ -160,11 +160,26 @@ local function entity_follow_query_page(mode, identity, limit, cursor, direction values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" local subject_uri = "record::jsonb->'subject'->>'uri'" - local sql = "WITH ranked_follows AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. entity_follow_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. "), " .. - "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_follows WHERE relationship_rank = 1), " .. - "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ") " .. - "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + local ranked_follows = "ranked_follows AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. entity_follow_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. ")" + local representatives = "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_follows WHERE relationship_rank = 1)" + local page = "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ")" + local sql + if mode == "followers" then + sql = "WITH " .. ranked_follows .. ", " .. representatives .. ", total AS (SELECT COUNT(*) AS total_count FROM representatives), " .. page .. " " .. + "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, total.total_count, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM total LEFT JOIN page ON TRUE ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + else + sql = "WITH " .. ranked_follows .. ", " .. representatives .. ", " .. page .. " " .. + "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + end local rows = entity_follow_query(sql, values) + local total_count + if mode == "followers" then + total_count = rows[1] and tonumber(rows[1].total_count) + if not total_count or total_count < 0 or total_count % 1 ~= 0 then + error("EntityFollowQueryFailed: entity-follow count unavailable", 0) + end + if rows[1].uri == nil then rows = {} end + end local more = #rows > limit if more then rows[#rows] = nil end @@ -176,7 +191,7 @@ local function entity_follow_query_page(mode, identity, limit, cursor, direction end next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) end - return rows, next_cursor + return rows, next_cursor, total_count end local ENTITY_FOLLOW_FOLLOWER_PROFILE = "app.certified.actor.profile" @@ -237,8 +252,8 @@ local function list_entity_followers() local limit = parse_list_limit(params) local direction = parse_sort_direction(params) local cursor = entity_follow_decode_cursor(scalar(params, "cursor"), direction) - local rows, next_cursor = entity_follow_query_page("followers", entity_uri, limit, cursor, direction) - local response = { followers = toarray(entity_follow_follower_views(rows)) } + local rows, next_cursor, total_count = entity_follow_query_page("followers", entity_uri, limit, cursor, direction) + local response = { followers = toarray(entity_follow_follower_views(rows)), totalCount = total_count } if next_cursor then response.cursor = next_cursor end return response end diff --git a/api/lua/endpoints/listEntityFollowing.lua b/api/lua/endpoints/listEntityFollowing.lua index 7848983..2533517 100644 --- a/api/lua/endpoints/listEntityFollowing.lua +++ b/api/lua/endpoints/listEntityFollowing.lua @@ -596,11 +596,26 @@ local function entity_follow_query_page(mode, identity, limit, cursor, direction values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" local subject_uri = "record::jsonb->'subject'->>'uri'" - local sql = "WITH ranked_follows AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. entity_follow_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. "), " .. - "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_follows WHERE relationship_rank = 1), " .. - "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ") " .. - "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + local ranked_follows = "ranked_follows AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. entity_follow_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. ")" + local representatives = "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_follows WHERE relationship_rank = 1)" + local page = "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ")" + local sql + if mode == "followers" then + sql = "WITH " .. ranked_follows .. ", " .. representatives .. ", total AS (SELECT COUNT(*) AS total_count FROM representatives), " .. page .. " " .. + "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, total.total_count, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM total LEFT JOIN page ON TRUE ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + else + sql = "WITH " .. ranked_follows .. ", " .. representatives .. ", " .. page .. " " .. + "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + end local rows = entity_follow_query(sql, values) + local total_count + if mode == "followers" then + total_count = rows[1] and tonumber(rows[1].total_count) + if not total_count or total_count < 0 or total_count % 1 ~= 0 then + error("EntityFollowQueryFailed: entity-follow count unavailable", 0) + end + if rows[1].uri == nil then rows = {} end + end local more = #rows > limit if more then rows[#rows] = nil end @@ -612,7 +627,7 @@ local function entity_follow_query_page(mode, identity, limit, cursor, direction end next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) end - return rows, next_cursor + return rows, next_cursor, total_count end local ENTITY_FOLLOW_ACTIVITY = "org.hypercerts.claim.activity" diff --git a/api/lua/shared/entityFollowPagination.lua b/api/lua/shared/entityFollowPagination.lua index 7b916b9..ecafb5c 100644 --- a/api/lua/shared/entityFollowPagination.lua +++ b/api/lua/shared/entityFollowPagination.lua @@ -40,11 +40,26 @@ local function entity_follow_query_page(mode, identity, limit, cursor, direction values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" local subject_uri = "record::jsonb->'subject'->>'uri'" - local sql = "WITH ranked_follows AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. entity_follow_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. "), " .. - "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_follows WHERE relationship_rank = 1), " .. - "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ") " .. - "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + local ranked_follows = "ranked_follows AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. entity_follow_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. ")" + local representatives = "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_follows WHERE relationship_rank = 1)" + local page = "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ")" + local sql + if mode == "followers" then + sql = "WITH " .. ranked_follows .. ", " .. representatives .. ", total AS (SELECT COUNT(*) AS total_count FROM representatives), " .. page .. " " .. + "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, total.total_count, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM total LEFT JOIN page ON TRUE ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + else + sql = "WITH " .. ranked_follows .. ", " .. representatives .. ", " .. page .. " " .. + "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + end local rows = entity_follow_query(sql, values) + local total_count + if mode == "followers" then + total_count = rows[1] and tonumber(rows[1].total_count) + if not total_count or total_count < 0 or total_count % 1 ~= 0 then + error("EntityFollowQueryFailed: entity-follow count unavailable", 0) + end + if rows[1].uri == nil then rows = {} end + end local more = #rows > limit if more then rows[#rows] = nil end @@ -56,5 +71,5 @@ local function entity_follow_query_page(mode, identity, limit, cursor, direction end next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) end - return rows, next_cursor + return rows, next_cursor, total_count end diff --git a/api/lua/src/listEntityFollowers.lua b/api/lua/src/listEntityFollowers.lua index 55b0e83..f105941 100644 --- a/api/lua/src/listEntityFollowers.lua +++ b/api/lua/src/listEntityFollowers.lua @@ -8,8 +8,8 @@ local function list_entity_followers() local limit = parse_list_limit(params) local direction = parse_sort_direction(params) local cursor = entity_follow_decode_cursor(scalar(params, "cursor"), direction) - local rows, next_cursor = entity_follow_query_page("followers", entity_uri, limit, cursor, direction) - local response = { followers = toarray(entity_follow_follower_views(rows)) } + local rows, next_cursor, total_count = entity_follow_query_page("followers", entity_uri, limit, cursor, direction) + local response = { followers = toarray(entity_follow_follower_views(rows)), totalCount = total_count } if next_cursor then response.cursor = next_cursor end return response end diff --git a/api/tests/http/entity-follow.http.test.js b/api/tests/http/entity-follow.http.test.js index e82493d..1e604ce 100644 --- a/api/tests/http/entity-follow.http.test.js +++ b/api/tests/http/entity-follow.http.test.js @@ -27,6 +27,7 @@ async function collectPages(nsid, outputKey, params, expectedUris) { for (let index = 0; index < expectedUris.length; index += 1) { const { response, body } = await request(nsid, { ...params, limit: 1, sortDirection: 'asc', cursor }); assert.equal(response.status, 200, `${nsid}: ${JSON.stringify(body)}`); + assert.equal(body.totalCount, expectedUris.length); assert.equal(body[outputKey].length, 1); const [item] = body[outputKey]; assert.equal(item.follow.uri, expectedUris[index]); @@ -91,6 +92,23 @@ test('listEntityFollowers filters by target, deduplicates tied records, paginate assert.equal(items[2].did, graphDids.secondPublisher); assert.equal(items[2].profile, null); assert.equal(items[2].organization, null); + + const beyondEndCursor = Buffer.from(JSON.stringify({ + v: 1, + d: 'desc', + t: '2020-01-01T00:00:00.000000Z', + u: `at://${graphDids.publisher}/${entityFollowCollection}/beyond-end`, + }), 'utf8').toString('hex'); + const { response, body } = await request('app.certified.graph.listEntityFollowers', { + entity: graphUris.followedFeature, + limit: 1, + sortDirection: 'desc', + cursor: beyondEndCursor, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.followers, []); + assert.equal(body.totalCount, 3); + assert.equal(Object.hasOwn(body, 'cursor'), false); }); test('listEntityFollowing resolves supported feature targets and preserves unresolved target URIs across tied pages', async () => { diff --git a/api/tests/unit/tooling/entity-follow.test.js b/api/tests/unit/tooling/entity-follow.test.js index 218e98f..360d29e 100644 --- a/api/tests/unit/tooling/entity-follow.test.js +++ b/api/tests/unit/tooling/entity-follow.test.js @@ -119,7 +119,7 @@ db = { dofile('lua/endpoints/listEntityFollowing.lua') local ok, result = pcall(handle) assert(ok, tostring(result)) -assert(#result.entities == 1 and result.cursor ~= nil) +assert(#result.entities == 1 and result.cursor ~= nil and result.totalCount == nil) local item = result.entities[1] assert(item.uri == FIXTURE.entityUri) assert(item.follow.uri == FIXTURE.follows[1].uri and item.follow.did == FIXTURE.actor) @@ -271,7 +271,7 @@ test('entity-follow listing selects earliest representatives before direction-bo for (const scenario of cases) { const follows = scenario.rows.map(({ did, key, createdAt, timestamp }) => ({ uri: `at://${did}/${entityFollow}/${key}`, did, cid: `bafy-${key}`, - indexed_at: '2025-01-05T00:00:00Z', record: key, sort_timestamp: timestamp, + indexed_at: '2025-01-05T00:00:00Z', record: key, sort_timestamp: timestamp, total_count: 4, follow_record: { $type: entityFollow, subject: { uri: followedEntity }, createdAt }, })); const records = Object.fromEntries(follows.map(({ record, follow_record }) => [record, follow_record])); @@ -314,7 +314,7 @@ db = { dofile('lua/endpoints/listEntityFollowers.lua') local ok, result = pcall(handle) assert(ok, tostring(result)) -assert(#result.followers == 1 and result.cursor ~= nil and result.totalCount == nil) +assert(#result.followers == 1 and result.cursor ~= nil and result.totalCount == 4) assert(result.followers[1].did == '${scenario.rows[0].did}') assert(result.followers[1].follow.record.createdAt == '${scenario.rows[0].createdAt}') local sql = calls[1].sql @@ -327,8 +327,11 @@ assert(sql:find('ORDER BY sorted.sort_at ASC, uri ASC', 1, true), 'choose the ea assert(sql:find("jsonb_typeof(record::jsonb->'createdAt') = 'string'", 1, true)) assert(sql:find('ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END', 1, true)) local representative = assert(sql:find('relationship_rank = 1', 1, true)) +local total_cte = assert(sql:find('total AS (SELECT COUNT(*) AS total_count FROM representatives)', 1, true)) +local page_cte = assert(sql:find('page AS (SELECT', 1, true)) local cursor_filter = assert(sql:find('(sort_at, uri) ${scenario.operator}', 1, true)) -assert(representative < cursor_filter, 'apply the cursor only after representative selection') +assert(representative < total_cte and total_cte < page_cte and page_cte < cursor_filter, + 'count deduplicated relationships before cursor-filtered page selection') assert(sql:find('ORDER BY sort_at ${scenario.ordering}, uri ${scenario.ordering}', 1, true)) local token_json = result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) local token = decode_cursor(token_json) @@ -418,8 +421,8 @@ test('listEntityFollowers preserves follower identity and hydrates profiles and const lookahead = 'did:plc:dddddddddddddddddddddddd'; const entity = 'at://did:plc:eeeeeeeeeeeeeeeeeeeeeeee/org.hypercerts.entity.feature/target'; const follows = [ - { uri: `at://${follower}/${entityFollow}/incoming`, did: follower, cid: 'bafy-incoming', indexed_at: null, record: 'follow-incoming', sort_timestamp: '2025-01-03T00:00:00.000000Z', follow_record: { $type: entityFollow, subject: { uri: entity }, createdAt: '2025-01-03T00:00:00Z' } }, - { uri: `at://${lookahead}/${entityFollow}/lookahead`, did: lookahead, cid: 'bafy-lookahead', indexed_at: '2025-01-02T00:00:00Z', record: 'follow-lookahead', sort_timestamp: '2025-01-02T00:00:00.000000Z', follow_record: { $type: entityFollow, subject: { uri: entity }, createdAt: '2025-01-02T00:00:00Z' } }, + { uri: `at://${follower}/${entityFollow}/incoming`, did: follower, cid: 'bafy-incoming', indexed_at: null, record: 'follow-incoming', sort_timestamp: '2025-01-03T00:00:00.000000Z', total_count: 5, follow_record: { $type: entityFollow, subject: { uri: entity }, createdAt: '2025-01-03T00:00:00Z' } }, + { uri: `at://${lookahead}/${entityFollow}/lookahead`, did: lookahead, cid: 'bafy-lookahead', indexed_at: '2025-01-02T00:00:00Z', record: 'follow-lookahead', sort_timestamp: '2025-01-02T00:00:00.000000Z', total_count: 5, follow_record: { $type: entityFollow, subject: { uri: entity }, createdAt: '2025-01-02T00:00:00Z' } }, ]; const profile = { uri: `at://${follower}/${profileCollection}/self`, did: follower, cid: 'bafy-incoming-profile', indexed_at: '2025-01-01T00:00:00Z', record: 'incoming-profile', sidecar_record: { $type: profileCollection, displayName: 'Incoming follower' } }; const organization = { uri: `at://${follower}/${organizationCollection}/self`, did: follower, cid: 'bafy-incoming-org', indexed_at: '2025-01-01T00:00:00Z', record: 'incoming-org', sidecar_record: { $type: organizationCollection, organizationType: ['nonprofit'] } }; @@ -467,7 +470,7 @@ db = { dofile('lua/endpoints/listEntityFollowers.lua') local ok, result = pcall(handle) assert(ok, tostring(result)) -assert(#result.followers == 1 and result.cursor ~= nil and result.totalCount == nil) +assert(#result.followers == 1 and result.cursor ~= nil and result.totalCount == 5) local item = result.followers[1] assert(item.did == FIXTURE.follows[1].did and item.follow.did == FIXTURE.follows[1].did) assert(item['$type'] == 'app.certified.graph.listEntityFollowers#entityFollowerView') @@ -488,6 +491,61 @@ assert(token.u == FIXTURE.follows[1].uri and token.d == 'desc') runLua('listEntityFollowers', source); }); +test('listEntityFollowers retains the deduplicated total on an empty beyond-end page', () => { + const entity = 'at://did:plc:eeeeeeeeeeeeeeeeeeeeeeee/org.hypercerts.entity.feature/target'; + const cursor = Buffer.from(JSON.stringify({ + v: 1, + d: 'desc', + t: '2025-01-01T00:00:00.000000Z', + u: `at://${actor}/${entityFollow}/cursor`, + }), 'utf8').toString('hex'); + const source = ` +local FIXTURE = ${lua({ actor, entityFollow, entity })} +local calls = {} +local totalCount = 4 +local NULL_VALUE = {} +json = { + decode = function(value) + if value == 'null' then return NULL_VALUE end + return { + v = tonumber(value:match('"v":(%d+)')), + d = value:match('"d":"([^"]*)"'), + t = value:match('"t":"([^"]*)"'), + u = value:match('"u":"([^"]*)"'), + } + end, +} +toarray = function(value) return value end +params = { entity = FIXTURE.entity, limit = '1', sortDirection = 'desc', cursor = '${cursor}' } +db = { + backend = function() return 'postgres' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + if values[1] == FIXTURE.entityFollow then + if sql:find('LEFT JOIN page', 1, true) then return { { total_count = totalCount } } end + return {} + end + error('empty follower pages must not hydrate sidecars') + end, +} +dofile('lua/endpoints/listEntityFollowers.lua') +local ok, result = pcall(handle) +assert(ok, tostring(result)) +assert(#result.followers == 0 and result.cursor == nil) +assert(result.totalCount == 4 and type(result.totalCount) == 'number') +assert(#calls == 1) +local sql = calls[1].sql +assert(sql:find('total AS (SELECT COUNT(*) AS total_count FROM representatives)', 1, true)) +assert(sql:find('LEFT JOIN page ON TRUE', 1, true), 'preserve the aggregate row when the cursor yields no page rows') +totalCount = 0 +ok, result = pcall(handle) +assert(ok, tostring(result)) +assert(#result.followers == 0 and result.totalCount == 0, 'an empty relationship set returns a numeric zero') +assert(#calls == 2) +`; + runLua('listEntityFollowers', source); +}); + test('listEntityFollowing returns hydrated ActivityView and CollectionView unions without expanding collection items', () => { const activityDid = 'did:plc:cccccccccccccccccccccccc'; const collectionDid = 'did:plc:dddddddddddddddddddddddd'; @@ -737,12 +795,15 @@ test('entity-follow module owns its view Lexicons and declares their shared depe assert.deepEqual(Object.keys(get.defs.main.parameters.properties).sort(), ['actor', 'entity']); assert.deepEqual(get.defs.output.required, ['follow']); assert.deepEqual(get.defs.output.nullable, ['follow']); - for (const [document, outputKey] of [[followers, 'followers'], [followingDoc, 'entities']]) { - assert.equal(document.defs.main.parameters.properties.limit.minimum, 1); - assert.equal(document.defs.main.parameters.properties.limit.maximum, 100); - assert.deepEqual(document.defs.output.required, [outputKey]); - assert.equal(Object.hasOwn(document.defs.output.properties, 'totalCount'), false); - } + assert.equal(followers.defs.main.parameters.properties.limit.minimum, 1); + assert.equal(followers.defs.main.parameters.properties.limit.maximum, 100); + assert.deepEqual(followers.defs.output.required, ['followers', 'totalCount']); + assert.equal(followers.defs.output.properties.totalCount.type, 'integer'); + assert.equal(followers.defs.output.properties.totalCount.minimum, 0); + assert.equal(Object.hasOwn(followingDoc.defs.output.properties, 'totalCount'), false); + assert.deepEqual(followingDoc.defs.output.required, ['entities']); + assert.equal(followingDoc.defs.main.parameters.properties.limit.minimum, 1); + assert.equal(followingDoc.defs.main.parameters.properties.limit.maximum, 100); }); test('listEntityFollowing names unknown query parameters in InvalidRequest diagnostics', () => { diff --git a/docs/openapi.json b/docs/openapi.json index d1247de..51b1f2a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1857,7 +1857,7 @@ "tags": [ "app.certified.graph" ], - "description": "Lists actors following an entity AT-URI. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated: missing profile and organization sidecars are null, while lookup failures return errors rather than partial pages.", + "description": "Lists actors following an entity AT-URI. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. totalCount reports distinct relationships before cursor and limit. Only the returned page is hydrated: missing profile and organization sidecars are null, while lookup failures return errors rather than partial pages.", "parameters": [ { "name": "entity", @@ -6116,7 +6116,8 @@ "app.certified.graph.listEntityFollowers.output": { "type": "object", "required": [ - "followers" + "followers", + "totalCount" ], "properties": { "followers": { @@ -6125,6 +6126,11 @@ "$ref": "#/components/schemas/app.certified.graph.listEntityFollowers.entityFollowerView" } }, + "totalCount": { + "type": "integer", + "description": "Count of distinct actor–entity relationships after duplicate records are collapsed, before cursor and limit are applied.", + "minimum": 0 + }, "cursor": { "type": "string", "description": "Next-page cursor; omitted when no more results are available." diff --git a/docs/sources/lexicons/app.certified.graph.listEntityFollowers.json b/docs/sources/lexicons/app.certified.graph.listEntityFollowers.json index ada7c73..6e18cad 100644 --- a/docs/sources/lexicons/app.certified.graph.listEntityFollowers.json +++ b/docs/sources/lexicons/app.certified.graph.listEntityFollowers.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Lists actors following an entity AT-URI. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated: missing profile and organization sidecars are null, while lookup failures return errors rather than partial pages.", + "description": "Lists actors following an entity AT-URI. No authentication is required; duplicate actor–entity relationships use the earliest createdAt, with URI ascending on ties. totalCount reports distinct relationships before cursor and limit. Only the returned page is hydrated: missing profile and organization sidecars are null, while lookup failures return errors rather than partial pages.", "parameters": { "type": "params", "required": [ @@ -85,7 +85,8 @@ "output": { "type": "object", "required": [ - "followers" + "followers", + "totalCount" ], "properties": { "followers": { @@ -95,6 +96,11 @@ "ref": "#entityFollowerView" } }, + "totalCount": { + "type": "integer", + "minimum": 0, + "description": "Count of distinct actor–entity relationships after duplicate records are collapsed, before cursor and limit are applied." + }, "cursor": { "type": "string", "description": "Next-page cursor; omitted when no more results are available."