diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 7144dc09..3a6c6952 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -86,7 +86,7 @@ An `unanswered` award is a successful indexed award result, not a not-found resp | 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. | -Do not confuse actor follows with entity follows; their identifiers and list outputs differ. Recent raw follows preserve duplicates and are not a snapshot; consult that endpoint's description before using cursors. +Account-follow endpoints (`getFollow`, `getFollows`, `listActorFollowers`, `listActorFollowing`, `getFollowerCounts`, and account records in `listRecentFollows`) skip legacy project follows: a `graph.follow` whose `via` points to an `org.hypercerts.collection` owned by the followed DID. Other `via` values (starter pack, list, another DID's collection) are kept, and `entityFollow` is unaffected. Expect lower org follower counts than older responses showed; the lists and counts agree. Do not confuse actor follows with entity follows; their identifiers and list outputs differ. Recent raw follows preserve duplicates and are not a snapshot; consult that endpoint's description before using cursors. ## Feed reactions (6) diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index f7a78976..4a65f76b 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -1431,7 +1431,7 @@ "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.", + "description": "Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "actor", @@ -1470,7 +1470,7 @@ "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "subjects", @@ -1520,7 +1520,7 @@ "get": { "operationId": "app_certified_graph_getFollows", "tags": ["app.certified.graph"], - "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "actor", @@ -1573,7 +1573,7 @@ "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "actor", @@ -1628,7 +1628,7 @@ "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "actor", @@ -1799,7 +1799,7 @@ "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.", + "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. Legacy project follows are not listed: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is skipped. Follows with any other via are listed.", "parameters": [ { "name": "before", diff --git a/.changeset/hide-legacy-project-follows.md b/.changeset/hide-legacy-project-follows.md new file mode 100644 index 00000000..96f513c5 --- /dev/null +++ b/.changeset/hide-legacy-project-follows.md @@ -0,0 +1,8 @@ +--- +'@hypercerts-org/happyview-modules': patch +'@hypercerts-org/api': patch +--- + +Follower counts and follow lists no longer include old project follows saved as account follows. + +Some apps followed a project by saving an account follow whose `via` points to the followed organization's own collection. Those records are now ignored by `getFollow`, `getFollows`, `listActorFollowers`, `listActorFollowing`, `getFollowerCounts` and `listRecentFollows`, so organization follower counts may drop. Follows with any other `via` (a starter pack, a list, or another account's collection) are unchanged, and nothing is deleted. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e86d960..0317d66a 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. Batched comment counts cover per-record counting, CID-independent subject matching, excluded non-comment attachments, and agreement with `listAttachments`. - 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. 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. +- Graph actor/entity lookups and lists, tied-key pagination, nullable profile/organization sidecars, entity target resolution, entity-follower totals across pages and empty pages, the global recent-follows `before` filter, and exclusion of legacy project follows (a `via` collection owned by the followed DID) across follow lookups, lists, counts, and the recent feed while real `via` follows stay. 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/openapi.json b/docs/openapi.json index 3e5c0f94..3a164509 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1846,7 +1846,7 @@ "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.", + "description": "Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "actor", @@ -1900,7 +1900,7 @@ "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "subjects", @@ -1956,7 +1956,7 @@ "tags": [ "app.certified.graph" ], - "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "actor", @@ -2023,7 +2023,7 @@ "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "actor", @@ -2100,7 +2100,7 @@ "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": [ { "name": "actor", @@ -2331,7 +2331,7 @@ "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.", + "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. Legacy project follows are not listed: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is skipped. Follows with any other via are listed.", "parameters": [ { "name": "before", diff --git a/docs/sources/lexicons/app.certified.graph.getFollow.json b/docs/sources/lexicons/app.certified.graph.getFollow.json index d08a777a..a0bd8735 100644 --- a/docs/sources/lexicons/app.certified.graph.getFollow.json +++ b/docs/sources/lexicons/app.certified.graph.getFollow.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required.", + "description": "Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": [ diff --git a/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json b/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json index 2eb2e09e..df73a3e2 100644 --- a/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json +++ b/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json @@ -4,7 +4,7 @@ "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": [ diff --git a/docs/sources/lexicons/app.certified.graph.getFollows.json b/docs/sources/lexicons/app.certified.graph.getFollows.json index 311cbf4d..281bf789 100644 --- a/docs/sources/lexicons/app.certified.graph.getFollows.json +++ b/docs/sources/lexicons/app.certified.graph.getFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": [ diff --git a/docs/sources/lexicons/app.certified.graph.listActorFollowers.json b/docs/sources/lexicons/app.certified.graph.listActorFollowers.json index d148c3e8..830a82f3 100644 --- a/docs/sources/lexicons/app.certified.graph.listActorFollowers.json +++ b/docs/sources/lexicons/app.certified.graph.listActorFollowers.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": [ diff --git a/docs/sources/lexicons/app.certified.graph.listActorFollowing.json b/docs/sources/lexicons/app.certified.graph.listActorFollowing.json index aa61b5c0..c1639745 100644 --- a/docs/sources/lexicons/app.certified.graph.listActorFollowing.json +++ b/docs/sources/lexicons/app.certified.graph.listActorFollowing.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": [ diff --git a/docs/sources/lexicons/app.certified.graph.listRecentFollows.json b/docs/sources/lexicons/app.certified.graph.listRecentFollows.json index d74c5263..769f7f30 100644 --- a/docs/sources/lexicons/app.certified.graph.listRecentFollows.json +++ b/docs/sources/lexicons/app.certified.graph.listRecentFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "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.", + "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. Legacy project follows are not listed: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is skipped. Follows with any other via are listed.", "parameters": { "type": "params", "properties": { diff --git a/happyview-modules/README.md b/happyview-modules/README.md index a6f1a817..d1e96ea6 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. +Account-follow queries (`getFollow`, `getFollows`, `listActorFollowers`, `listActorFollowing`, `getFollowerCounts`, and the account records in `listRecentFollows`) share one filter, `lua/shared/legacyProjectFollow.lua`, that skips legacy project follows: an `app.certified.graph.follow` whose `via` is a strongRef to an `org.hypercerts.collection` owned by the same DID as `subject`. Those clients used an account follow to follow a project; project follows belong in `app.certified.graph.entityFollow`. The records stay in the database and the filter runs at query time. A `via` that points to a starter pack, list, or a collection owned by another DID is kept. It runs before per-relationship deduplication, so a user who also has a genuine follow of the same account is still counted once. `entityFollow` queries are unchanged. + `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 diff --git a/happyview-modules/lexicons/app.certified.graph.getFollow.json b/happyview-modules/lexicons/app.certified.graph.getFollow.json index 48a8ad16..4dc3c327 100644 --- a/happyview-modules/lexicons/app.certified.graph.getFollow.json +++ b/happyview-modules/lexicons/app.certified.graph.getFollow.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required.", + "description": "Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": ["actor", "subject"], diff --git a/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json b/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json index a866c710..954e5506 100644 --- a/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json +++ b/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json @@ -4,7 +4,7 @@ "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": ["subjects"], diff --git a/happyview-modules/lexicons/app.certified.graph.getFollows.json b/happyview-modules/lexicons/app.certified.graph.getFollows.json index ce7b8c14..b2c92f09 100644 --- a/happyview-modules/lexicons/app.certified.graph.getFollows.json +++ b/happyview-modules/lexicons/app.certified.graph.getFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": ["actor", "subjects"], diff --git a/happyview-modules/lexicons/app.certified.graph.listActorFollowers.json b/happyview-modules/lexicons/app.certified.graph.listActorFollowers.json index a19d76b1..93a1137f 100644 --- a/happyview-modules/lexicons/app.certified.graph.listActorFollowers.json +++ b/happyview-modules/lexicons/app.certified.graph.listActorFollowers.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": ["actor"], diff --git a/happyview-modules/lexicons/app.certified.graph.listActorFollowing.json b/happyview-modules/lexicons/app.certified.graph.listActorFollowing.json index 90fb2a3c..9199f94f 100644 --- a/happyview-modules/lexicons/app.certified.graph.listActorFollowing.json +++ b/happyview-modules/lexicons/app.certified.graph.listActorFollowing.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "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.", + "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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept.", "parameters": { "type": "params", "required": ["actor"], diff --git a/happyview-modules/lexicons/app.certified.graph.listRecentFollows.json b/happyview-modules/lexicons/app.certified.graph.listRecentFollows.json index 006877d1..9bf701f6 100644 --- a/happyview-modules/lexicons/app.certified.graph.listRecentFollows.json +++ b/happyview-modules/lexicons/app.certified.graph.listRecentFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "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.", + "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. Legacy project follows are not listed: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is skipped. Follows with any other via are listed.", "parameters": { "type": "params", "properties": { diff --git a/happyview-modules/lua/endpoints/getFollow.lua b/happyview-modules/lua/endpoints/getFollow.lua index 5ee2ff47..1d2dfe41 100644 --- a/happyview-modules/lua/endpoints/getFollow.lua +++ b/happyview-modules/lua/endpoints/getFollow.lua @@ -39,6 +39,22 @@ local function record_view(row) } end +-- Some clients wrote account follows whose `via` points at the followed +-- account's own org.hypercerts.collection, using the account follow as a +-- project follow. Those records stay indexed but are not account follows, so +-- every account-follow query applies this predicate in its WHERE clause, before +-- any per-relationship deduplication, so a user's genuine follow of the same +-- account is still found. +-- +-- The rule is deliberately narrow: only a collection owned by the subject +-- matches. A starter pack, list, or collection owned by another DID is a real +-- mediating record and is kept. COALESCE keeps records with no usable `via`. +-- The expression only reads the row's own `record`, so it adds a per-row filter +-- on rows already selected by collection, subject, or publisher. +local function not_legacy_project_follow_sql() + return "NOT COALESCE(starts_with(record::jsonb->'via'->>'uri', 'at://' || (record::jsonb->>'subject') || '/org.hypercerts.collection/'), FALSE)" +end + local FOLLOW = "app.certified.graph.follow" -- Guard the cast inside CASE; a WHERE filter cannot protect it from planner reordering. @@ -57,7 +73,7 @@ end local function query_follow(actor, subject) local rows = query( - "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records CROSS JOIN LATERAL (SELECT " .. follow_sort_key() .. " AS sort_at) sorted WHERE collection = $1 AND did = $2 AND record::jsonb->>'subject' = $3 ORDER BY sorted.sort_at ASC, uri ASC LIMIT 1", + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records CROSS JOIN LATERAL (SELECT " .. follow_sort_key() .. " AS sort_at) sorted WHERE collection = $1 AND did = $2 AND record::jsonb->>'subject' = $3 AND " .. not_legacy_project_follow_sql() .. " ORDER BY sorted.sort_at ASC, uri ASC LIMIT 1", { FOLLOW, actor, subject }) if #rows == 0 then return NULL end return record_view(rows[1]) diff --git a/happyview-modules/lua/endpoints/getFollowerCounts.lua b/happyview-modules/lua/endpoints/getFollowerCounts.lua index c3aebc78..cd6c8aea 100644 --- a/happyview-modules/lua/endpoints/getFollowerCounts.lua +++ b/happyview-modules/lua/endpoints/getFollowerCounts.lua @@ -77,6 +77,22 @@ local function valid_subject_record_uri(value) return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end +-- Some clients wrote account follows whose `via` points at the followed +-- account's own org.hypercerts.collection, using the account follow as a +-- project follow. Those records stay indexed but are not account follows, so +-- every account-follow query applies this predicate in its WHERE clause, before +-- any per-relationship deduplication, so a user's genuine follow of the same +-- account is still found. +-- +-- The rule is deliberately narrow: only a collection owned by the subject +-- matches. A starter pack, list, or collection owned by another DID is a real +-- mediating record and is kept. COALESCE keeps records with no usable `via`. +-- The expression only reads the row's own `record`, so it adds a per-row filter +-- on rows already selected by collection, subject, or publisher. +local function not_legacy_project_follow_sql() + return "NOT COALESCE(starts_with(record::jsonb->'via'->>'uri', 'at://' || (record::jsonb->>'subject') || '/org.hypercerts.collection/'), FALSE)" +end + local FOLLOW_BATCH_MAX_TARGETS = 100 local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 7000 @@ -182,7 +198,7 @@ local function follower_count_query(dids, entity_uris) 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 + " IN (" .. follower_count_placeholders(values, dids) .. ") AND " .. not_legacy_project_follow_sql() .. " GROUP BY " .. subject end if #entity_uris > 0 then values[#values + 1] = ENTITY_FOLLOW diff --git a/happyview-modules/lua/endpoints/getFollows.lua b/happyview-modules/lua/endpoints/getFollows.lua index 9467185c..f6294673 100644 --- a/happyview-modules/lua/endpoints/getFollows.lua +++ b/happyview-modules/lua/endpoints/getFollows.lua @@ -39,6 +39,22 @@ local function record_view(row) } end +-- Some clients wrote account follows whose `via` points at the followed +-- account's own org.hypercerts.collection, using the account follow as a +-- project follow. Those records stay indexed but are not account follows, so +-- every account-follow query applies this predicate in its WHERE clause, before +-- any per-relationship deduplication, so a user's genuine follow of the same +-- account is still found. +-- +-- The rule is deliberately narrow: only a collection owned by the subject +-- matches. A starter pack, list, or collection owned by another DID is a real +-- mediating record and is kept. COALESCE keeps records with no usable `via`. +-- The expression only reads the row's own `record`, so it adds a per-row filter +-- on rows already selected by collection, subject, or publisher. +local function not_legacy_project_follow_sql() + return "NOT COALESCE(starts_with(record::jsonb->'via'->>'uri', 'at://' || (record::jsonb->>'subject') || '/org.hypercerts.collection/'), FALSE)" +end + local FOLLOW = "app.certified.graph.follow" -- Guard the cast inside CASE; a WHERE filter cannot protect it from planner reordering. @@ -143,7 +159,7 @@ local function get_follows() "WITH requested(target) AS (VALUES " .. table.concat(requested, ", ") .. ") " .. "SELECT DISTINCT ON (requested.target) requested.target AS target, uri, did, cid, " .. "indexed_at::text AS indexed_at, record::text AS record FROM requested " - .. "JOIN happyview_records ON collection = $1 AND did = $2 AND record::jsonb->>'subject' = requested.target " + .. "JOIN happyview_records ON collection = $1 AND did = $2 AND record::jsonb->>'subject' = requested.target AND " .. not_legacy_project_follow_sql() .. " " .. "CROSS JOIN LATERAL (SELECT " .. follow_sort_key() .. " AS sort_at) sorted " .. "ORDER BY requested.target, sorted.sort_at ASC, uri ASC", values) diff --git a/happyview-modules/lua/endpoints/listActorFollowers.lua b/happyview-modules/lua/endpoints/listActorFollowers.lua index b9353695..5d21601e 100644 --- a/happyview-modules/lua/endpoints/listActorFollowers.lua +++ b/happyview-modules/lua/endpoints/listActorFollowers.lua @@ -123,6 +123,22 @@ local function hydrate_actor_views(actors, run_query) end end +-- Some clients wrote account follows whose `via` points at the followed +-- account's own org.hypercerts.collection, using the account follow as a +-- project follow. Those records stay indexed but are not account follows, so +-- every account-follow query applies this predicate in its WHERE clause, before +-- any per-relationship deduplication, so a user's genuine follow of the same +-- account is still found. +-- +-- The rule is deliberately narrow: only a collection owned by the subject +-- matches. A starter pack, list, or collection owned by another DID is a real +-- mediating record and is kept. COALESCE keeps records with no usable `via`. +-- The expression only reads the row's own `record`, so it adds a per-row filter +-- on rows already selected by collection, subject, or publisher. +local function not_legacy_project_follow_sql() + return "NOT COALESCE(starts_with(record::jsonb->'via'->>'uri', 'at://' || (record::jsonb->>'subject') || '/org.hypercerts.collection/'), FALSE)" +end + local FOLLOW = "app.certified.graph.follow" -- Guard the cast inside CASE; a WHERE filter cannot protect it from planner reordering. @@ -161,7 +177,7 @@ local function cursor_decode(token, direction) end local function build_actor_follow_query(mode, actor, limit, cursor, direction) - local filters, values = { "collection = $1" }, { FOLLOW } + local filters, values = { "collection = $1", not_legacy_project_follow_sql() }, { FOLLOW } values[#values + 1] = actor if mode == "followers" then filters[#filters + 1] = "record::jsonb->>'subject' = $2" diff --git a/happyview-modules/lua/endpoints/listActorFollowing.lua b/happyview-modules/lua/endpoints/listActorFollowing.lua index 53fadb78..c29a08f9 100644 --- a/happyview-modules/lua/endpoints/listActorFollowing.lua +++ b/happyview-modules/lua/endpoints/listActorFollowing.lua @@ -123,6 +123,22 @@ local function hydrate_actor_views(actors, run_query) end end +-- Some clients wrote account follows whose `via` points at the followed +-- account's own org.hypercerts.collection, using the account follow as a +-- project follow. Those records stay indexed but are not account follows, so +-- every account-follow query applies this predicate in its WHERE clause, before +-- any per-relationship deduplication, so a user's genuine follow of the same +-- account is still found. +-- +-- The rule is deliberately narrow: only a collection owned by the subject +-- matches. A starter pack, list, or collection owned by another DID is a real +-- mediating record and is kept. COALESCE keeps records with no usable `via`. +-- The expression only reads the row's own `record`, so it adds a per-row filter +-- on rows already selected by collection, subject, or publisher. +local function not_legacy_project_follow_sql() + return "NOT COALESCE(starts_with(record::jsonb->'via'->>'uri', 'at://' || (record::jsonb->>'subject') || '/org.hypercerts.collection/'), FALSE)" +end + local FOLLOW = "app.certified.graph.follow" -- Guard the cast inside CASE; a WHERE filter cannot protect it from planner reordering. @@ -161,7 +177,7 @@ local function cursor_decode(token, direction) end local function build_actor_follow_query(mode, actor, limit, cursor, direction) - local filters, values = { "collection = $1" }, { FOLLOW } + local filters, values = { "collection = $1", not_legacy_project_follow_sql() }, { FOLLOW } values[#values + 1] = actor if mode == "followers" then filters[#filters + 1] = "record::jsonb->>'subject' = $2" diff --git a/happyview-modules/lua/endpoints/listRecentFollows.lua b/happyview-modules/lua/endpoints/listRecentFollows.lua index c94b30d9..808419e6 100644 --- a/happyview-modules/lua/endpoints/listRecentFollows.lua +++ b/happyview-modules/lua/endpoints/listRecentFollows.lua @@ -70,6 +70,22 @@ local function parse_list_limit(params) return limit end +-- Some clients wrote account follows whose `via` points at the followed +-- account's own org.hypercerts.collection, using the account follow as a +-- project follow. Those records stay indexed but are not account follows, so +-- every account-follow query applies this predicate in its WHERE clause, before +-- any per-relationship deduplication, so a user's genuine follow of the same +-- account is still found. +-- +-- The rule is deliberately narrow: only a collection owned by the subject +-- matches. A starter pack, list, or collection owned by another DID is a real +-- mediating record and is kept. COALESCE keeps records with no usable `via`. +-- The expression only reads the row's own `record`, so it adds a per-row filter +-- on rows already selected by collection, subject, or publisher. +local function not_legacy_project_follow_sql() + return "NOT COALESCE(starts_with(record::jsonb->'via'->>'uri', 'at://' || (record::jsonb->>'subject') || '/org.hypercerts.collection/'), FALSE)" +end + local ACCOUNT_FOLLOW = "app.certified.graph.follow" local ENTITY_FOLLOW = "app.certified.graph.entityFollow" local NULL = json.decode("null") @@ -163,7 +179,7 @@ local function recent_follows_response() local sql = "WITH candidates AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at " .. "FROM happyview_records CROSS JOIN LATERAL (SELECT " .. recent_created_at_sort_key() .. " AS sort_at) AS sorted " .. - "WHERE collection IN ($1, $2) AND cid <> ''), page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM candidates WHERE " .. + "WHERE collection IN ($1, $2) AND cid <> '' AND (collection <> $1 OR " .. not_legacy_project_follow_sql() .. ")), page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM candidates WHERE " .. table.concat(predicates, " AND ") .. " ORDER BY sort_at DESC, uri DESC LIMIT $" .. #values .. ") " .. "SELECT uri, did, cid, indexed_at, record, to_char(sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. "FROM page ORDER BY sort_at DESC, uri DESC" diff --git a/happyview-modules/lua/shared/actorFollowList.lua b/happyview-modules/lua/shared/actorFollowList.lua index ff8106a0..908065c0 100644 --- a/happyview-modules/lua/shared/actorFollowList.lua +++ b/happyview-modules/lua/shared/actorFollowList.lua @@ -20,7 +20,7 @@ local function cursor_decode(token, direction) end local function build_actor_follow_query(mode, actor, limit, cursor, direction) - local filters, values = { "collection = $1" }, { FOLLOW } + local filters, values = { "collection = $1", not_legacy_project_follow_sql() }, { FOLLOW } values[#values + 1] = actor if mode == "followers" then filters[#filters + 1] = "record::jsonb->>'subject' = $2" diff --git a/happyview-modules/lua/shared/actorFollowLookup.lua b/happyview-modules/lua/shared/actorFollowLookup.lua index 24c2bd90..9ae3ca3f 100644 --- a/happyview-modules/lua/shared/actorFollowLookup.lua +++ b/happyview-modules/lua/shared/actorFollowLookup.lua @@ -1,6 +1,6 @@ local function query_follow(actor, subject) local rows = query( - "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records CROSS JOIN LATERAL (SELECT " .. follow_sort_key() .. " AS sort_at) sorted WHERE collection = $1 AND did = $2 AND record::jsonb->>'subject' = $3 ORDER BY sorted.sort_at ASC, uri ASC LIMIT 1", + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records CROSS JOIN LATERAL (SELECT " .. follow_sort_key() .. " AS sort_at) sorted WHERE collection = $1 AND did = $2 AND record::jsonb->>'subject' = $3 AND " .. not_legacy_project_follow_sql() .. " ORDER BY sorted.sort_at ASC, uri ASC LIMIT 1", { FOLLOW, actor, subject }) if #rows == 0 then return NULL end return record_view(rows[1]) diff --git a/happyview-modules/lua/shared/legacyProjectFollow.lua b/happyview-modules/lua/shared/legacyProjectFollow.lua new file mode 100644 index 00000000..1d80d505 --- /dev/null +++ b/happyview-modules/lua/shared/legacyProjectFollow.lua @@ -0,0 +1,15 @@ +-- Some clients wrote account follows whose `via` points at the followed +-- account's own org.hypercerts.collection, using the account follow as a +-- project follow. Those records stay indexed but are not account follows, so +-- every account-follow query applies this predicate in its WHERE clause, before +-- any per-relationship deduplication, so a user's genuine follow of the same +-- account is still found. +-- +-- The rule is deliberately narrow: only a collection owned by the subject +-- matches. A starter pack, list, or collection owned by another DID is a real +-- mediating record and is kept. COALESCE keeps records with no usable `via`. +-- The expression only reads the row's own `record`, so it adds a per-row filter +-- on rows already selected by collection, subject, or publisher. +local function not_legacy_project_follow_sql() + return "NOT COALESCE(starts_with(record::jsonb->'via'->>'uri', 'at://' || (record::jsonb->>'subject') || '/org.hypercerts.collection/'), FALSE)" +end diff --git a/happyview-modules/lua/shared/recentFollows.lua b/happyview-modules/lua/shared/recentFollows.lua index 57c6c78e..0e0265de 100644 --- a/happyview-modules/lua/shared/recentFollows.lua +++ b/happyview-modules/lua/shared/recentFollows.lua @@ -91,7 +91,7 @@ local function recent_follows_response() local sql = "WITH candidates AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at " .. "FROM happyview_records CROSS JOIN LATERAL (SELECT " .. recent_created_at_sort_key() .. " AS sort_at) AS sorted " .. - "WHERE collection IN ($1, $2) AND cid <> ''), page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM candidates WHERE " .. + "WHERE collection IN ($1, $2) AND cid <> '' AND (collection <> $1 OR " .. not_legacy_project_follow_sql() .. ")), page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM candidates WHERE " .. table.concat(predicates, " AND ") .. " ORDER BY sort_at DESC, uri DESC LIMIT $" .. #values .. ") " .. "SELECT uri, did, cid, indexed_at, record, to_char(sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. "FROM page ORDER BY sort_at DESC, uri DESC" diff --git a/happyview-modules/lua/src/getFollowerCounts.lua b/happyview-modules/lua/src/getFollowerCounts.lua index 93b6cf62..a61fb6d0 100644 --- a/happyview-modules/lua/src/getFollowerCounts.lua +++ b/happyview-modules/lua/src/getFollowerCounts.lua @@ -32,7 +32,7 @@ local function follower_count_query(dids, entity_uris) 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 + " IN (" .. follower_count_placeholders(values, dids) .. ") AND " .. not_legacy_project_follow_sql() .. " GROUP BY " .. subject end if #entity_uris > 0 then values[#values + 1] = ENTITY_FOLLOW diff --git a/happyview-modules/lua/src/getFollows.lua b/happyview-modules/lua/src/getFollows.lua index 2ec59589..a75f0afe 100644 --- a/happyview-modules/lua/src/getFollows.lua +++ b/happyview-modules/lua/src/getFollows.lua @@ -15,7 +15,7 @@ local function get_follows() "WITH requested(target) AS (VALUES " .. table.concat(requested, ", ") .. ") " .. "SELECT DISTINCT ON (requested.target) requested.target AS target, uri, did, cid, " .. "indexed_at::text AS indexed_at, record::text AS record FROM requested " - .. "JOIN happyview_records ON collection = $1 AND did = $2 AND record::jsonb->>'subject' = requested.target " + .. "JOIN happyview_records ON collection = $1 AND did = $2 AND record::jsonb->>'subject' = requested.target AND " .. not_legacy_project_follow_sql() .. " " .. "CROSS JOIN LATERAL (SELECT " .. follow_sort_key() .. " AS sort_at) sorted " .. "ORDER BY requested.target, sorted.sort_at ASC, uri ASC", values) diff --git a/happyview-modules/modules/actor-follow/manifest.json b/happyview-modules/modules/actor-follow/manifest.json index a6d1ccac..fff88777 100644 --- a/happyview-modules/modules/actor-follow/manifest.json +++ b/happyview-modules/modules/actor-follow/manifest.json @@ -49,6 +49,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordView.lua", + "../../lua/shared/legacyProjectFollow.lua", "../../lua/shared/actorFollow.lua", "../../lua/shared/actorFollowLookup.lua" ], @@ -67,6 +68,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordView.lua", + "../../lua/shared/legacyProjectFollow.lua", "../../lua/shared/actorFollow.lua", "../../lua/shared/followBatch.lua" ], @@ -90,6 +92,7 @@ "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", "../../lua/shared/actorView.lua", + "../../lua/shared/legacyProjectFollow.lua", "../../lua/shared/actorFollow.lua", "../../lua/shared/actorFollowList.lua" ], @@ -113,6 +116,7 @@ "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", "../../lua/shared/actorView.lua", + "../../lua/shared/legacyProjectFollow.lua", "../../lua/shared/actorFollow.lua", "../../lua/shared/actorFollowList.lua" ], diff --git a/happyview-modules/modules/follower-counts/manifest.json b/happyview-modules/modules/follower-counts/manifest.json index 492462fb..8217614b 100644 --- a/happyview-modules/modules/follower-counts/manifest.json +++ b/happyview-modules/modules/follower-counts/manifest.json @@ -20,6 +20,7 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/nsidValidation.lua", "../../lua/shared/subjectRecordUri.lua", + "../../lua/shared/legacyProjectFollow.lua", "../../lua/shared/followBatch.lua" ], "config": { diff --git a/happyview-modules/modules/recent-follows/manifest.json b/happyview-modules/modules/recent-follows/manifest.json index 8ce5e26c..6dc2c87d 100644 --- a/happyview-modules/modules/recent-follows/manifest.json +++ b/happyview-modules/modules/recent-follows/manifest.json @@ -20,6 +20,7 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", + "../../lua/shared/legacyProjectFollow.lua", "../../lua/shared/recentFollows.lua" ], "config": { diff --git a/happyview-modules/tests/http/fixtures/graph-follows.fixture.js b/happyview-modules/tests/http/fixtures/graph-follows.fixture.js index 30cbf9b8..a4f1c04e 100644 --- a/happyview-modules/tests/http/fixtures/graph-follows.fixture.js +++ b/happyview-modules/tests/http/fixtures/graph-follows.fixture.js @@ -18,6 +18,12 @@ export const graphDids = { entityAuthor: "did:plc:rrrrrrrrrrrrrrrrrrrrrrrr", curator: "did:plc:ssssssssssssssssssssssss", thirdFollower: "did:plc:tttttttttttttttttttttttt", + // Legacy project-follow scenario: an org whose own collection was used as `via`. + legacyOrg: "did:plc:111111111111111111111111", + legacyOnly: "did:plc:222222222222222222222222", + legacyThenPlain: "did:plc:333333333333333333333333", + legitViaOtherCollection: "did:plc:444444444444444444444444", + legitViaStarterPack: "did:plc:555555555555555555555555", }; export const graphUris = { @@ -101,6 +107,52 @@ const entityFollows = [ }), ]; +const legacyViaCollection = `at://${graphDids.legacyOrg}/org.hypercerts.collection/3jzfcijpj2z2a`; +const cid = "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy"; + +export const legacyFollowUris = { + legacyOnly: `at://${graphDids.legacyOnly}/${accountFollow}/3jzfcijpj2z2a`, + legacyEarlier: `at://${graphDids.legacyThenPlain}/${accountFollow}/3jzfcijpj2z2a`, + plainLater: `at://${graphDids.legacyThenPlain}/${accountFollow}/3jzfcijpj2z2b`, + legitCollection: `at://${graphDids.legitViaOtherCollection}/${accountFollow}/3jzfcijpj2z2a`, + legitStarterPack: `at://${graphDids.legitViaStarterPack}/${accountFollow}/3jzfcijpj2z2a`, +}; + +// Rows 1-2 are legacy project follows (via is the followed org's own collection). +// Row 3 is the same publisher's genuine follow. Rows 4-5 have a real mediating record. +const legacyProjectFollows = [ + seedRow(accountFollow, graphDids.legacyOnly, "3jzfcijpj2z2a", { + subject: graphDids.legacyOrg, + createdAt: "2025-02-13T00:00:00.000Z", + via: { uri: legacyViaCollection, cid }, + }), + seedRow(accountFollow, graphDids.legacyThenPlain, "3jzfcijpj2z2a", { + subject: graphDids.legacyOrg, + createdAt: "2025-02-10T23:00:00.000Z", + via: { uri: legacyViaCollection, cid }, + }), + seedRow(accountFollow, graphDids.legacyThenPlain, "3jzfcijpj2z2b", { + subject: graphDids.legacyOrg, + createdAt: "2025-02-11T00:00:00.000Z", + }), + seedRow(accountFollow, graphDids.legitViaOtherCollection, "3jzfcijpj2z2a", { + subject: graphDids.legacyOrg, + createdAt: "2025-02-12T00:00:00.000Z", + via: { + uri: `at://${graphDids.curator}/org.hypercerts.collection/3jzfcijpj2z2a`, + cid, + }, + }), + seedRow(accountFollow, graphDids.legitViaStarterPack, "3jzfcijpj2z2a", { + subject: graphDids.legacyOrg, + createdAt: "2025-02-12T01:00:00.000Z", + via: { + uri: `at://${graphDids.legacyOrg}/app.bsky.graph.starterpack/3jzfcijpj2z2a`, + cid, + }, + }), +]; + const actorSidecars = [ seedRow(profile, graphDids.publisher, "self", { displayName: "Graph Fixture Publisher", @@ -149,6 +201,7 @@ const features = [ export const seedRows = await Promise.all([ ...accountFollows, + ...legacyProjectFollows, ...entityFollows, ...actorSidecars, ...features, diff --git a/happyview-modules/tests/http/graph-legacy-project-follows.http.test.js b/happyview-modules/tests/http/graph-legacy-project-follows.http.test.js new file mode 100644 index 00000000..8590a197 --- /dev/null +++ b/happyview-modules/tests/http/graph-legacy-project-follows.http.test.js @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { graphDids, legacyFollowUris } from "./fixtures/graph-follows.fixture.js"; +import { contractUrl, requireContractTarget } from "./helpers.js"; + +const baseUrl = requireContractTarget(); +const org = graphDids.legacyOrg; + +async function request(nsid, params) { + const response = await fetch(contractUrl(baseUrl, nsid, params), { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + const body = await response.json(); + assert.equal(response.status, 200, JSON.stringify(body)); + return body; +} + +// Followers of the org: the publisher with a legacy project follow and a genuine follow +// counts once (through the genuine record); the legacy-only publisher is absent. +const expectedFollowers = [ + graphDids.legitViaStarterPack, + graphDids.legitViaOtherCollection, + graphDids.legacyThenPlain, +]; + +test("listActorFollowers excludes legacy project follows and keeps real via follows", async () => { + const body = await request("app.certified.graph.listActorFollowers", { actor: org }); + + assert.deepEqual( + body.followers.map(({ did }) => did), + expectedFollowers, + ); + assert.equal(body.totalCount, expectedFollowers.length); + const representative = body.followers.find(({ did }) => did === graphDids.legacyThenPlain); + assert.equal(representative.follow.uri, legacyFollowUris.plainLater); + assert.equal(representative.follow.record.via, undefined); +}); + +test("listActorFollowing omits an org that was only followed through a legacy project follow", async () => { + const legacyOnly = await request("app.certified.graph.listActorFollowing", { + actor: graphDids.legacyOnly, + }); + assert.deepEqual(legacyOnly.following, []); + assert.equal(legacyOnly.totalCount, 0); + + const legit = await request("app.certified.graph.listActorFollowing", { + actor: graphDids.legitViaOtherCollection, + }); + assert.equal(legit.totalCount, 1); + assert.equal(legit.following[0].follow.uri, legacyFollowUris.legitCollection); +}); + +test("getFollow and getFollows return null for legacy project follows and the genuine record when both exist", async () => { + const legacyOnly = await request("app.certified.graph.getFollow", { + actor: graphDids.legacyOnly, + subject: org, + }); + assert.equal(legacyOnly.follow, null); + + const both = await request("app.certified.graph.getFollow", { + actor: graphDids.legacyThenPlain, + subject: org, + }); + assert.equal(both.follow.uri, legacyFollowUris.plainLater); + + const legit = await request("app.certified.graph.getFollow", { + actor: graphDids.legitViaStarterPack, + subject: org, + }); + assert.equal(legit.follow.uri, legacyFollowUris.legitStarterPack); + + const batch = await request("app.certified.graph.getFollows", { + actor: graphDids.legacyOnly, + subjects: [org], + }); + assert.deepEqual(batch.results, [{ subject: org, follow: null }]); +}); + +test("getFollowerCounts, listActorFollowers and listRecentFollows agree on legacy project follows", async () => { + const counts = await request("app.certified.graph.getFollowerCounts", { subjects: [org] }); + assert.deepEqual(counts.counts, [{ subject: org, followerCount: expectedFollowers.length }]); + + const list = await request("app.certified.graph.listActorFollowers", { actor: org, limit: 1 }); + assert.equal(list.totalCount, counts.counts[0].followerCount); + + const uris = []; + let cursor; + do { + const page = await request("app.certified.graph.listRecentFollows", { limit: 5, cursor }); + uris.push(...page.follows.map(({ uri }) => uri)); + cursor = page.cursor; + } while (cursor); + assert.ok(uris.includes(legacyFollowUris.legitCollection)); + assert.ok(uris.includes(legacyFollowUris.legitStarterPack)); + assert.ok(uris.includes(legacyFollowUris.plainLater)); + assert.ok(!uris.includes(legacyFollowUris.legacyOnly)); + assert.ok(!uris.includes(legacyFollowUris.legacyEarlier)); +}); diff --git a/happyview-modules/tests/http/recent-follows.http.test.js b/happyview-modules/tests/http/recent-follows.http.test.js index 42e0e5fc..ef1af274 100644 --- a/happyview-modules/tests/http/recent-follows.http.test.js +++ b/happyview-modules/tests/http/recent-follows.http.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { graphDids } from "./fixtures/graph-follows.fixture.js"; +import { graphDids, legacyFollowUris } from "./fixtures/graph-follows.fixture.js"; import { contractUrl, requireContractTarget } from "./helpers.js"; const baseUrl = requireContractTarget(); @@ -36,7 +36,11 @@ async function request(params = {}) { } test("listRecentFollows returns the exact global feed across tied-key pages, including baseline account follows", async () => { + // The two legacy project follows (legacyOnly, legacyEarlier) are indexed but never listed. const expectedUris = [ + legacyFollowUris.legitStarterPack, + legacyFollowUris.legitCollection, + legacyFollowUris.plainLater, `at://${graphDids.secondPublisher}/${accountFollow}/3jzfcijpj2z2e`, `at://${graphDids.secondPublisher}/${entityFollow}/3jzfcijpj2z2e`, `at://${graphDids.publisher}/${accountFollow}/3jzfcijpj2z2d`, @@ -67,8 +71,8 @@ test("listRecentFollows returns the exact global feed across tied-key pages, inc records.map(({ uri }) => uri), expectedUris, ); - assert.equal(records[0].record.$type, accountFollow); - assert.equal(records[1].record.$type, entityFollow); + assert.equal(records[3].record.$type, accountFollow); + assert.equal(records[4].record.$type, entityFollow); assert.equal( records.filter( ({ uri }) => uri === `at://${graphDids.publisher}/${accountFollow}/3jzfcijpj2z2a`, diff --git a/happyview-modules/tests/unit/lua/recentFollows.test.lua b/happyview-modules/tests/unit/lua/recentFollows.test.lua index 53ba24e8..1bf0d617 100644 --- a/happyview-modules/tests/unit/lua/recentFollows.test.lua +++ b/happyview-modules/tests/unit/lua/recentFollows.test.lua @@ -95,6 +95,7 @@ local handler = table.concat({ source("lua/shared/recordIdentifier.lua"), source("lua/shared/datetimeValidation.lua"), source("lua/shared/listValidation.lua"), + source("lua/shared/legacyProjectFollow.lua"), source("lua/shared/recentFollows.lua"), source("lua/src/listRecentFollows.lua"), }, "\n\n") @@ -127,6 +128,8 @@ end local ok, result = invoke({}) assert(ok, tostring(result)) assert(calls[1].sql:find("cid <> ''", 1, true), "SQL excludes save_local rows with empty CIDs") +assert(calls[1].sql:find("(collection <> $1 OR NOT COALESCE(starts_with(record::jsonb->'via'->>'uri'", 1, true), + "legacy project follows are excluded from the account-follow collection only") assert(type(result.follows) == "table" and #result.follows == 2, "both network follow records survive while the local-only fixture is excluded") assert(result.follows[1].indexedAt == json_null, "NULL indexed_at is emitted as explicit JSON null") assert(result.follows[1].uri == rows[1].uri and result.follows[2].uri == rows[2].uri, diff --git a/happyview-modules/tests/unit/tooling/actor-follow.test.js b/happyview-modules/tests/unit/tooling/actor-follow.test.js index 3ee06314..58f88fbd 100644 --- a/happyview-modules/tests/unit/tooling/actor-follow.test.js +++ b/happyview-modules/tests/unit/tooling/actor-follow.test.js @@ -679,3 +679,21 @@ test("actor-follow queries surface the beta PostgreSQL-only and operational fail expectedCalls: 1, }); }); + +test("every account-follow lookup and list applies the legacy project-follow exclusion", () => { + const predicate = + "starts_with(record::jsonb->'via'->>'uri', 'at://' || (record::jsonb->>'subject') || '/org.hypercerts.collection/')"; + const cases = [ + ["getFollow", { actor, subject }], + ["getFollows", { actor, subjects: subject }], + ["listActorFollowers", { actor }], + ["listActorFollowing", { actor }], + ]; + for (const [endpoint, params] of cases) { + runLua({ + endpoint, + params, + assertions: `assert(calls[1].sql:find(${JSON.stringify(predicate)}, 1, true), '${endpoint} must filter legacy project follows')`, + }); + } +}); diff --git a/happyview-modules/tests/unit/tooling/follower-counts.test.js b/happyview-modules/tests/unit/tooling/follower-counts.test.js index 12bd59eb..47dcfbba 100644 --- a/happyview-modules/tests/unit/tooling/follower-counts.test.js +++ b/happyview-modules/tests/unit/tooling/follower-counts.test.js @@ -91,6 +91,20 @@ end }); }); +test("getFollowerCounts excludes legacy project follows from account counts only", () => { + runLua({ + params: { subjects: [account, entity] }, + assertions: ` +local sql = calls[1].sql +local branches = {} +for branch in (sql .. ' UNION ALL '):gmatch('(.-) UNION ALL ') do branches[#branches + 1] = branch end +assert(#branches == 2) +assert(branches[1]:find("starts_with(record::jsonb->'via'->>'uri'", 1, true), 'account branch filters legacy project follows') +assert(not branches[2]:find('starts_with', 1, true), 'entity branch is unchanged') +`, + }); +}); + test("getFollowerCounts queries only the collection a single-kind batch needs", () => { runLua({ params: { subjects: [account] }, diff --git a/packages/api/src/lexicons/app/certified/graph/getFollow.defs.ts b/packages/api/src/lexicons/app/certified/graph/getFollow.defs.ts index 411ca157..86d2bc92 100644 --- a/packages/api/src/lexicons/app/certified/graph/getFollow.defs.ts +++ b/packages/api/src/lexicons/app/certified/graph/getFollow.defs.ts @@ -21,7 +21,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required. */ +/** Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest"]); export { main }; diff --git a/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.defs.ts b/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.defs.ts index 9e5aeba1..face74e7 100644 --- a/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.defs.ts +++ b/packages/api/src/lexicons/app/certified/graph/getFollowerCounts.defs.ts @@ -19,7 +19,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ 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. */ +/** 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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","FollowerCountQueryFailed"]); export { main }; diff --git a/packages/api/src/lexicons/app/certified/graph/getFollows.defs.ts b/packages/api/src/lexicons/app/certified/graph/getFollows.defs.ts index 50923d5d..bb112991 100644 --- a/packages/api/src/lexicons/app/certified/graph/getFollows.defs.ts +++ b/packages/api/src/lexicons/app/certified/graph/getFollows.defs.ts @@ -20,7 +20,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes. */ +/** Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","ActorFollowQueryFailed"]); export { main }; diff --git a/packages/api/src/lexicons/app/certified/graph/listActorFollowers.defs.ts b/packages/api/src/lexicons/app/certified/graph/listActorFollowers.defs.ts index b3987ee1..11ac21ba 100644 --- a/packages/api/src/lexicons/app/certified/graph/listActorFollowers.defs.ts +++ b/packages/api/src/lexicons/app/certified/graph/listActorFollowers.defs.ts @@ -20,7 +20,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** 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. */ +/** 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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest"]); export { main }; diff --git a/packages/api/src/lexicons/app/certified/graph/listActorFollowing.defs.ts b/packages/api/src/lexicons/app/certified/graph/listActorFollowing.defs.ts index 6fff8ac4..d58b830f 100644 --- a/packages/api/src/lexicons/app/certified/graph/listActorFollowing.defs.ts +++ b/packages/api/src/lexicons/app/certified/graph/listActorFollowing.defs.ts @@ -20,7 +20,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** 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. */ +/** 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. Legacy project follows are excluded: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is not an account follow. Follows with any other via are kept. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest"]); export { main }; diff --git a/packages/api/src/lexicons/app/certified/graph/listRecentFollows.defs.ts b/packages/api/src/lexicons/app/certified/graph/listRecentFollows.defs.ts index d197cf57..5cc373d0 100644 --- a/packages/api/src/lexicons/app/certified/graph/listRecentFollows.defs.ts +++ b/packages/api/src/lexicons/app/certified/graph/listRecentFollows.defs.ts @@ -21,7 +21,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** 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. */ +/** 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. Legacy project follows are not listed: an account follow whose via points to an org.hypercerts.collection owned by the followed DID is skipped. Follows with any other via are listed. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest"]); export { main };