diff --git a/.agents/skills/hypercerts-api/SKILL.md b/.agents/skills/hypercerts-api/SKILL.md index ac375f2..b5a9a91 100644 --- a/.agents/skills/hypercerts-api/SKILL.md +++ b/.agents/skills/hypercerts-api/SKILL.md @@ -20,7 +20,7 @@ Read the relevant reference, then inspect the exact operation in the [bundled Op ## Bundled API contract -- [`references/openapi.json`](references/openapi.json) is a self-contained snapshot of all 61 query operations, including parameters, response schemas, declared XRPC errors, and their referenced schema definitions. The included contract makes the skill self-contained when installed separately. +- [`references/openapi.json`](references/openapi.json) is a self-contained snapshot of all 62 query operations, including parameters, response schemas, declared XRPC errors, and their referenced schema definitions. The included contract makes the skill self-contained when installed separately. - To inspect an exact contract, find `/xrpc/` in the snapshot, read its operation, and follow any `#/components/schemas/...` references by schema name. Each operation and schema is stored on its own line for focused lookup. - The snapshot describes declared behavior, not endpoint availability or data freshness on a particular host. Its OpenAPI `info.version` is not the deployed server's version. - Use the API directly when a task needs live indexed results. Do not send extra diagnostic probes just to validate deployment; a deployment-specific availability check requires explicit approval for the target. diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 2b03e00..3dd2ae3 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -1,6 +1,6 @@ # Endpoint map: choose by consumer task -This task index lists the 61 public query NSIDs in the bundled [OpenAPI contract](openapi.json). It helps choose an operation but does not replace its exact contract: inspect that operation and its referenced schemas for required parameters, types, defaults, limits, filter semantics, response fields, and declared errors. The static snapshot describes the contract, not availability or freshness on a selected deployment. +This task index lists the 62 public query NSIDs in the bundled [OpenAPI contract](openapi.json). It helps choose an operation but does not replace its exact contract: inspect that operation and its referenced schemas for required parameters, types, defaults, limits, filter semantics, response fields, and declared errors. The static snapshot describes the contract, not availability or freshness on a selected deployment. ## Fast selection rules @@ -9,7 +9,7 @@ This task index lists the 61 public query NSIDs in the bundled [OpenAPI contract - **Browse or filter without free text:** choose `list...` in the relevant domain. - **Text discovery:** use `search...` only where listed below. A search query is not interchangeable with a list filter, and search semantics vary by endpoint. - **Relationship checks:** use `get...Follow` for a single pair, or `getFollows` / `getEntityFollows` to check repeated target values for one actor. List queries answer who follows whom or which entities an actor follows. -- **Feed reactions:** use `getLikes` or `getReposts` for distinct actors reacting to a subject URI, or `getActorLikes` / `getActorReposts` for an actor's own raw representative reactions. +- **Feed reactions:** use `getLikes` or `getReposts` for distinct actors reacting to one subject URI, `getActorLikes` / `getActorReposts` for one actor's raw representative reactions, or `getActorReactions` to check one actor across a bounded set of known subject URIs. Inspect the exact operation in the bundled OpenAPI contract before constructing a request: names such as `authors`, `actors`, `uri`, and `collection` have endpoint-specific meanings. Do not infer that a filter, search operation, output array name, or error exists just because a related endpoint has one. @@ -85,7 +85,7 @@ An `unanswered` award is a successful indexed award result, not a not-found resp Do not confuse actor follows with entity follows; their identifiers and list outputs differ. Recent raw follows preserve duplicates and are not a snapshot; consult that endpoint's description before using cursors. -## Feed reactions (4) +## Feed reactions (5) | User task | Query NSID | Practical choice | |---|---|---| @@ -93,8 +93,9 @@ Do not confuse actor follows with entity follows; their identifiers and list out | List actors who reposted a record | `app.certified.feed.getReposts` | `subject` is a full record AT-URI; matching ignores CID. The response includes a total distinct-actor count, paginated actor sidecars, and each actor's raw representative repost. | | List an actor's likes | `app.certified.feed.getActorLikes` | `actor` is a repository-owner DID. Returns raw like records, one per subject URI; subjects need not be indexed. | | List an actor's reposts | `app.certified.feed.getActorReposts` | `actor` is a repository-owner DID. Returns raw repost records, one per subject URI; subjects need not be indexed. | +| Check an actor's reactions for known subjects | `app.certified.feed.getActorReactions` | Supply `actor` as a DID and `subjects` as 1–100 repeated full record AT-URIs. Returns one result per input occurrence in order, including duplicates; matching ignores CID, and missing indexed likes/reposts are null. Raw views retain reaction URIs. | -Reaction queries use the earliest record per author/subject URI, with URI ascending as the tie-breaker. They sort pages by the representative record's `createdAt` and URI; check each operation for exact cursor and response details. +Paginated reaction-list queries use the earliest record per author/subject URI, with reaction URI ascending on ties, and sort pages by the representative's `createdAt` and URI. `getActorReactions` uses the same representative selection but preserves request order and has no pagination. Check each operation for exact cursor and response details. ## Context and supporting records (8) diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index 53fbc86..19fa797 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -4,6 +4,7 @@ "servers": [{"url":"https://api.test.hypercerts.dev","description":"API test server (default)"}], "tags": [{"name":"app.certified.actor"},{"name":"app.certified.badge"},{"name":"app.certified.feed"},{"name":"app.certified.graph"},{"name":"app.certified.link"},{"name":"app.certified.location"},{"name":"org.hypercerts.claim"},{"name":"org.hypercerts.collection"},{"name":"org.hypercerts.context"},{"name":"org.hypercerts.entity"},{"name":"org.hypercerts.funding"},{"name":"org.hypercerts.vocab"},{"name":"org.hypercerts.workscope"}], "paths": { + "/xrpc/app.certified.feed.getActorReactions": {"get":{"operationId":"app_certified_feed_getActorReactions","tags":["app.certified.feed"],"description":"Returns the indexed like and repost for each supplied subject occurrence by one actor. Authentication is not required. Subject matching ignores CID; duplicate inputs and request order are preserved. For each reaction collection, duplicate actor and subject URI pairs use the earliest valid createdAt; when it is absent or invalid, ordering falls back to indexedAt and then the database row's created_at timestamp, with reaction URI ascending on ties. A missing indexed reaction is null; query failures return an error.","parameters":[{"name":"actor","in":"query","required":true,"description":"Repository-owner DID of the actor whose reactions to look up.","schema":{"type":"string","format":"did"}},{"name":"subjects","in":"query","required":true,"description":"One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order.","schema":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getActorReactions.output"}}}}},"x-lexicon-id":"app.certified.feed.getActorReactions","x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID, subjects array, subject AT-URI, or another query parameter is invalid."},{"name":"ActorReactionsQueryFailed","description":"The indexed actor reactions could not be queried or returned a malformed result; retry the request."}]}}, "/xrpc/app.certified.actor.getOrganization": {"get":{"operationId":"app_certified_actor_getOrganization","tags":["app.certified.actor"],"description":"Gets an actor's indexed organization sidecar and associated profile by DID. Authentication is not required.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID of the actor to look up.","schema":{"type":"string","format":"did"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.actor.getOrganization.output"}}}}},"x-lexicon-id":"app.certified.actor.getOrganization","x-hypercerts-coverage":"manifest-registered","x-hypercerts-source":{"module":"modules/organization/manifest.json"},"x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID is invalid or an unsupported parameter was supplied."},{"name":"RecordNotFound","description":"No indexed organization sidecar exists for this DID."},{"name":"OrganizationQueryFailed","description":"The indexed organization sidecar or associated profile could not be queried."}]}}, "/xrpc/app.certified.actor.getOrganizations": {"get":{"operationId":"app_certified_actor_getOrganizations","tags":["app.certified.actor"],"description":"Returns an indexed organization sidecar and associated profile for each supplied actor DID. Authentication is not required.","parameters":[{"name":"actors","in":"query","required":true,"description":"Actor DIDs to look up, supplied as repeated unbracketed query values.","schema":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.actor.getOrganizations.output"}}}}},"x-lexicon-id":"app.certified.actor.getOrganizations","x-hypercerts-coverage":"manifest-registered","x-hypercerts-source":{"module":"modules/organization/manifest.json"},"x-lexicon-errors":[{"name":"InvalidRequest","description":"An actor DID or query parameter is invalid."},{"name":"OrganizationQueryFailed","description":"The indexed organization sidecars or associated profiles could not be queried."}]}}, "/xrpc/app.certified.actor.getProfile": {"get":{"operationId":"app_certified_actor_getProfile","tags":["app.certified.actor"],"description":"Gets an indexed Certified profile by DID or handle. Handles resolve through the configured resolver's com.atproto.identity.resolveHandle endpoint; no DID document is fetched for independent verification. Authentication is not required.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID or handle of the profile actor.","schema":{"type":"string","format":"at-identifier"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.actor.getProfile.output"}}}}},"x-lexicon-id":"app.certified.actor.getProfile","x-hypercerts-coverage":"manifest-registered","x-hypercerts-source":{"module":"modules/profile/manifest.json"},"x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor is not a valid DID or handle, or an unsupported parameter was supplied."},{"name":"HandleResolverConfigError","description":"HYPERCERTS_HANDLE_RESOLVER_URL is missing or invalid."},{"name":"HandleResolutionFailed","description":"The handle could not be resolved to a valid DID."},{"name":"RecordNotFound","description":"The actor was identified, but no indexed Certified profile exists."},{"name":"ProfileQueryFailed","description":"The indexed profile could not be queried."}]}}, @@ -69,6 +70,8 @@ "components": { "schemas": { "app.certified.actor.profile.main": {"type":"record","description":"A declaration of a Certified account profile."}, + "app.certified.feed.getActorReactions.actorReactionResult": {"type":"object","required":["subject","like","repost"],"properties":{"subject":{"type":"string","description":"The subject AT-URI from this request occurrence; matching ignores CID.","format":"at-uri"},"like":{"anyOf":[{"$ref":"#/components/schemas/app.certified.feed.getLikes.likeRecordView"},{"type":"null"}]},"repost":{"anyOf":[{"$ref":"#/components/schemas/app.certified.feed.getReposts.repostRecordView"},{"type":"null"}]}}}, + "app.certified.feed.getActorReactions.output": {"type":"object","required":["results"],"properties":{"results":{"type":"array","description":"One result for each supplied subject occurrence, in request order, including duplicates.","items":{"$ref":"#/components/schemas/app.certified.feed.getActorReactions.actorReactionResult"}}}}, "org.hypercerts.api.defs.profileView": {"type":"object","description":"Profile record view preserving the full indexed record; indexedAt may be null when the index has no timestamp.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.actor.profile.main"}}}, "app.certified.actor.organization.main": {"type":"record","description":"Extended metadata for an organization actor. Complements the base actor profile (app.certified.actor.profile), which holds the display name, description, website, avatar, and banner, with organization-specific fields such as legal name, full logo, public contact email, legal structure, reference links, and locations."}, "org.hypercerts.api.defs.organizationView": {"type":"object","description":"Raw organization sidecar record view preserving the full indexed record; indexedAt may be null when the index has no timestamp.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.actor.organization.main"}}}, diff --git a/.agents/skills/hypercerts-api/references/recipes.md b/.agents/skills/hypercerts-api/references/recipes.md index 46ce213..ad1836b 100644 --- a/.agents/skills/hypercerts-api/references/recipes.md +++ b/.agents/skills/hypercerts-api/references/recipes.md @@ -22,6 +22,15 @@ These recipes show endpoint choice and the data to carry between calls. For exac **Likely failure modes:** passing a record URI where the required collection URI belongs; discarding CID and accidentally displaying a different version; assuming nested collections are expanded; or assuming pages represent one immutable collection snapshot. Inspect `org.hypercerts.collection.listCollectionItems` and `org.hypercerts.collection.getCollection` in the [bundled OpenAPI contract](openapi.json) before relying on resolution details. +## Check one actor's reactions for known subjects + +1. Call `app.certified.feed.getActorReactions` with `actor` as the repository-owner DID and `subjects` as 1–100 full record AT-URIs. Send each URI as a repeated unbracketed `subjects` query parameter; do not comma-join or bracket the key. +2. Keep the input order and duplicates when the caller needs one result per occurrence. The response's `results` array preserves both and repeats the requested AT-URI in each item's `subject` field. +3. Each result contains the actor's earliest indexed raw `like` and `repost` for that subject URI, or null for either reaction when none is indexed. Subject matching ignores CID. A non-null view preserves its reaction record `uri` and `cid`; retain them when the application needs to identify that record through a separate write-capable API. +4. A null is a successful indexed absence, not proof that no source record exists. Query failures are errors, not null reactions. Consider index freshness before showing a definitive “not reacted” state. + +**Likely failure modes:** passing handles instead of a DID, sending a collection-less or malformed AT-URI, exceeding the 100-subject bound, deduplicating the response by URI when occurrences matter, or treating a failed indexed query as absence. Inspect the operation in the [bundled OpenAPI contract](openapi.json) and see [reliability.md](reliability.md). + ## Load profile and organization context 1. For one actor profile, call `app.certified.actor.getProfile` with a DID or handle. For many known actor DIDs, use `app.certified.actor.getProfiles` with repeated `actors` values; the returned results correspond to the input occurrences and `profile` can be null. diff --git a/.agents/skills/hypercerts-api/references/reliability.md b/.agents/skills/hypercerts-api/references/reliability.md index e0fd2e6..2cb8531 100644 --- a/.agents/skills/hypercerts-api/references/reliability.md +++ b/.agents/skills/hypercerts-api/references/reliability.md @@ -27,12 +27,12 @@ For a potentially fresh record that is absent: ## Null, omitted, empty, not-found, and failure are different -- **Null field:** the endpoint returned a known enclosing result, but one optional or nullable projection is unavailable/absent under that contract. Examples include a null profile in batch `getProfiles`, a null organization in `getOrganizations`, a null `follow` in `getFollow`, or a null resolved collection-item `record`. Preserve and render this state intentionally. +- **Null field:** the endpoint returned a known enclosing result, but one optional or nullable projection is unavailable/absent under that contract. Examples include a null profile in batch `getProfiles`, a null organization in `getOrganizations`, a null `follow` in `getFollow`, a null resolved collection-item `record`, or a null `like`/`repost` from `getActorReactions` when that reaction is not indexed. Preserve and render this state intentionally. - **Batch relationship result:** `getFollows` and `getEntityFollows` return an explicit target and nullable `follow` for every input occurrence. Null means no relationship is indexed for that actor and target; a database/query failure remains an error and must not be converted to null. - **Omitted optional field:** the schema does not require the property in that condition. Do not treat omission as the same representation as explicit null unless the exact endpoint contract says so. - **Empty list:** the query succeeded and returned no matching items. It is not the same as an invalid query or failed hydration. - **Not-found error:** a `get...` query may declare `RecordNotFound` when no matching record is indexed. That is not necessarily a successful response containing a null record. Use the endpoint's own declared error contract. -- **Query/hydration error:** an operation may fail if required indexed data cannot be queried or hydrated. Do not hide it by returning an empty list or null unless the endpoint explicitly defines that behavior. +- **Query/hydration error:** an operation may fail if required indexed data cannot be queried or hydrated. `app.certified.feed.getActorReactions` returns `ActorReactionsQueryFailed` for a failed or malformed indexed lookup; it must not be interpreted as a successful null reaction. Do not hide query failures by returning an empty list or null unless the endpoint explicitly defines that behavior. - **Domain status:** `getBadgeAward` can return a valid award with `responseStatus: "unanswered"` and `recipientResponse: null`. That is neither a not-found error nor a failed request. The award view can separately have `badge: null` if its definition projection is unavailable. A nullable actor profile does not imply an invalid DID; a missing organization sidecar does not prove the actor is missing; an unavailable exact CID does not prove another version at that URI is absent. diff --git a/.agents/skills/hypercerts-api/references/request-patterns.md b/.agents/skills/hypercerts-api/references/request-patterns.md index 503a3d1..133e5c1 100644 --- a/.agents/skills/hypercerts-api/references/request-patterns.md +++ b/.agents/skills/hypercerts-api/references/request-patterns.md @@ -39,6 +39,22 @@ A useful starting distinction: arrays often mean “any of these” for a filter The batch relationship checks `app.certified.graph.getFollows` and `app.certified.graph.getEntityFollows` each take one `actor` and 1–100 repeated, unbracketed `subjects` or `entities` values. Keep duplicates if the caller supplied them: each output `results` entry echoes its target and preserves its position. The encoded GET request-target must stay within 7,000 bytes. A null `follow` means only that no indexed relationship was found; database failures are errors, not nulls. +### Batch an actor's reactions for known subjects + +`app.certified.feed.getActorReactions` requires one actor DID and 1–100 full record AT-URIs with DID authorities, not handle authorities. Encode `subjects` as repeated, unbracketed query parameters; keep input order and duplicates if each occurrence matters: + +```ts +const params = new URLSearchParams(); +params.set("actor", actorDid); +for (const subject of subjectUris) params.append("subjects", subject); +const url = new URL( + `/xrpc/app.certified.feed.getActorReactions?${params.toString()}`, + apiBase, +); +``` + +The response contains one `results` item per supplied occurrence in that same order. Each item repeats the explicit `subject` AT-URI and has `like` and `repost` fields that are either raw record views or null. Matching ignores the subject CID. A null means the successful indexed lookup found no such reaction; a query failure is an error, not null. Preserve a non-null view's `uri` and `cid` when you need to identify that reaction record elsewhere. + ## Keep page requests cursor-consistent For a list/search operation whose output has a cursor, keep a base set of all non-cursor parameters and replace only the cursor on continuation. Preserve repeated array values, filters, `search`, `limit`, and `sortDirection` exactly. The example below is illustrative; verify the chosen endpoint's cursor binding and response-array name. diff --git a/.changeset/actor-reactions-batch-lookup.md b/.changeset/actor-reactions-batch-lookup.md new file mode 100644 index 0000000..1a36241 --- /dev/null +++ b/.changeset/actor-reactions-batch-lookup.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': minor +--- + +Add `app.certified.feed.getActorReactions` to return an actor's indexed likes and reposts for up to 100 subject records in one request, preserving request order and duplicates. diff --git a/api/README.md b/api/README.md index 2cb087f..39c1ceb 100644 --- a/api/README.md +++ b/api/README.md @@ -12,9 +12,9 @@ Use the [`docs/` endpoint explorer](../docs/) and generated [OpenAPI reference]( ## Feed reaction capability -`modules/feed-reactions/manifest.json` owns the like and repost record schemas and four public queries: `app.certified.feed.getLikes`, `getReposts`, `getActorLikes`, and `getActorReposts`. The root manifest includes this independently installable module in the aggregate bundle. Its record schemas enable backfill, and queries read the current indexed records directly; deleted reactions therefore stop appearing once removed from HappyView's index, without a separate stored count. +`modules/feed-reactions/manifest.json` owns the like and repost record schemas and five public queries: `app.certified.feed.getLikes`, `getReposts`, `getActorLikes`, `getActorReposts`, and `getActorReactions`. The root manifest includes this independently installable module in the aggregate bundle. Its record schemas enable backfill, and queries read the current indexed records directly; deleted reactions therefore stop appearing once removed from HappyView's index, without a separate stored count. -Subject queries match by AT-URI without requiring a subject CID match, return one earliest reaction per author, and include a distinct-actor total with nullable profile and organization sidecars. Actor queries return raw reaction records without subject hydration. All four use bounded `createdAt`/URI cursor pagination. +Subject queries match by AT-URI without requiring a subject CID match, return one earliest reaction per author, and include a distinct-actor total with nullable profile and organization sidecars. Actor list queries return raw reaction records without subject hydration. `getActorReactions` looks up one actor's like and repost across 1–100 subject AT-URIs, preserving input order and duplicates; each raw reaction view retains its record URI, and a missing indexed reaction is null. Query failures return errors, not nulls. The four list queries use bounded `createdAt`/URI cursor pagination; the batch lookup is not paginated. ## Local validation diff --git a/api/lexicons/app.certified.feed.getActorReactions.json b/api/lexicons/app.certified.feed.getActorReactions.json new file mode 100644 index 0000000..ef78589 --- /dev/null +++ b/api/lexicons/app.certified.feed.getActorReactions.json @@ -0,0 +1,46 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getActorReactions", + "defs": { + "main": { + "type": "query", + "description": "Returns the indexed like and repost for each supplied subject occurrence by one actor. Authentication is not required. Subject matching ignores CID; duplicate inputs and request order are preserved. For each reaction collection, duplicate actor and subject URI pairs use the earliest valid createdAt; when it is absent or invalid, ordering falls back to indexedAt and then the database row's created_at timestamp, with reaction URI ascending on ties. A missing indexed reaction is null; query failures return an error.", + "parameters": { + "type": "params", + "required": ["actor", "subjects"], + "properties": { + "actor": { "type": "string", "format": "did", "description": "Repository-owner DID of the actor whose reactions to look up." }, + "subjects": { + "type": "array", + "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order.", + "minLength": 1, + "maxLength": 100, + "items": { "type": "string", "format": "at-uri" } + } + } + }, + "output": { "encoding": "application/json", "schema": { "type": "ref", "ref": "#output" } }, + "errors": [ + { "name": "InvalidRequest", "description": "The actor DID, subjects array, subject AT-URI, or another query parameter is invalid." }, + { "name": "ActorReactionsQueryFailed", "description": "The indexed actor reactions could not be queried or returned a malformed result; retry the request." } + ] + }, + "output": { + "type": "object", + "required": ["results"], + "properties": { + "results": { "type": "array", "description": "One result for each supplied subject occurrence, in request order, including duplicates.", "items": { "type": "ref", "ref": "#actorReactionResult" } } + } + }, + "actorReactionResult": { + "type": "object", + "required": ["subject", "like", "repost"], + "nullable": ["like", "repost"], + "properties": { + "subject": { "type": "string", "format": "at-uri", "description": "The subject AT-URI from this request occurrence; matching ignores CID." }, + "like": { "type": "ref", "ref": "app.certified.feed.getLikes#likeRecordView", "description": "The actor's earliest indexed like for this subject URI, or null when none is indexed." }, + "repost": { "type": "ref", "ref": "app.certified.feed.getReposts#repostRecordView", "description": "The actor's earliest indexed repost for this subject URI, or null when none is indexed." } + } + } + } +} diff --git a/api/lua/endpoints/getActorReactions.lua b/api/lua/endpoints/getActorReactions.lua new file mode 100644 index 0000000..717bad3 --- /dev/null +++ b/api/lua/endpoints/getActorReactions.lua @@ -0,0 +1,252 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local ACTOR_REACTION_NULL = json.decode("null") +local ACTOR_REACTION_LIMIT = 100 +local ACTOR_REACTION_COLLECTIONS = { + like = "app.certified.feed.like", + repost = "app.certified.feed.repost", +} + +local function actor_reaction_sort_key() + -- Keep representative selection aligned with feedReaction.lua's validated createdAt fallback. + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" +end + +local function valid_actor_reaction_nsid(value) + if type(value) ~= "string" or #value > 317 then return false end + local segments = {} + for segment in value:gmatch("[^%.]+") do segments[#segments + 1] = segment end + if #segments < 3 or table.concat(segments, ".") ~= value then return false end + for index = 1, #segments - 1 do + local segment = segments[index] + if #segment > 63 or segment:find("[^A-Za-z0-9%-]") then return false end + local first, last = segment:sub(1, 1), segment:sub(-1) + if not first:match(index == 1 and "^[A-Za-z]$" or "^[A-Za-z0-9]$") + or not last:match("^[A-Za-z0-9]$") then return false end + end + local name = segments[#segments] + return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil +end + +local function valid_actor_reaction_subject_uri(value) + local valid, collection = valid_record_uri(value) + return valid and valid_actor_reaction_nsid(collection) +end + +local function feed_actor_reaction_subjects(value) + if value == nil then invalid("subjects is required") end + + local subjects = {} + if type(value) == "string" then + subjects[1] = value + elseif type(value) == "table" then + local occurrences = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid("subjects must use repeated query values") + end + occurrences = occurrences + 1 + end + if occurrences ~= #value then invalid("subjects must use repeated query values") end + for index = 1, occurrences do + if type(value[index]) ~= "string" then invalid("subjects entries must be strings") end + subjects[#subjects + 1] = value[index] + end + else + invalid("subjects must be a string or repeated string parameter") + end + + if #subjects < 1 or #subjects > ACTOR_REACTION_LIMIT then + invalid("subjects must contain 1 through 100 AT-URIs") + end + for _, subject in ipairs(subjects) do + if #subject > 8192 or not valid_actor_reaction_subject_uri(subject) then + invalid("each subjects value must be a full DID-authority AT-URI with a valid collection NSID") + end + end + return subjects +end + +local function feed_actor_reactions_query(actor, subjects) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ActorReactionsQueryFailed: actor reaction queries require PostgreSQL", 0) + end + + local values, requested_rows = { actor }, {} + for ordinal, subject in ipairs(subjects) do + values[#values + 1] = subject + requested_rows[#requested_rows + 1] = "($" .. #values .. ", " .. ordinal .. ")" + end + + local likes = ACTOR_REACTION_COLLECTIONS.like + local reposts = ACTOR_REACTION_COLLECTIONS.repost + local sort_key = actor_reaction_sort_key() + local sql = "WITH requested(requested_subject, request_ordinal) AS (VALUES " .. table.concat(requested_rows, ", ") .. "), " .. + "requested_unique AS (SELECT DISTINCT requested_subject FROM requested), " .. + "ranked_reactions AS (SELECT reaction.collection, reaction.record::jsonb->'subject'->>'uri' AS subject_uri, " .. + "reaction.uri, reaction.did, reaction.cid, reaction.indexed_at::text AS indexed_at, reaction.record::text AS record, " .. + "ROW_NUMBER() OVER (PARTITION BY reaction.collection, reaction.record::jsonb->'subject'->>'uri' " .. + "ORDER BY sorted.sort_at ASC, reaction.uri ASC) AS reaction_rank " .. + "FROM happyview_records reaction JOIN requested_unique requested " .. + "ON requested.requested_subject = reaction.record::jsonb->'subject'->>'uri' " .. + "CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) sorted " .. + "WHERE reaction.did = $1 AND reaction.collection IN ('" .. likes .. "', '" .. reposts .. "')), " .. + "representatives AS (SELECT collection, subject_uri, uri, did, cid, indexed_at, record " .. + "FROM ranked_reactions WHERE reaction_rank = 1) " .. + "SELECT requested.request_ordinal, requested.requested_subject, " .. + "(likes.uri IS NOT NULL) AS like_present, likes.uri AS like_uri, likes.cid AS like_cid, " .. + "likes.indexed_at AS like_indexed_at, likes.did AS like_did, likes.record AS like_record, " .. + "(reposts.uri IS NOT NULL) AS repost_present, reposts.uri AS repost_uri, reposts.cid AS repost_cid, " .. + "reposts.indexed_at AS repost_indexed_at, reposts.did AS repost_did, reposts.record AS repost_record " .. + "FROM requested " .. + "LEFT JOIN representatives likes ON likes.collection = '" .. likes .. "' " .. + "AND likes.subject_uri = requested.requested_subject " .. + "LEFT JOIN representatives reposts ON reposts.collection = '" .. reposts .. "' " .. + "AND reposts.subject_uri = requested.requested_subject " .. + "ORDER BY requested.request_ordinal" + + local ok, rows = pcall(db.raw, sql, values) + if not ok or type(rows) ~= "table" then + error("ActorReactionsQueryFailed: indexed actor reactions could not be queried", 0) + end + return rows +end + +local function feed_actor_reaction_record_view(row, prefix, collection, actor, subject) + local present = row[prefix .. "_present"] + local uri = row[prefix .. "_uri"] + local cid = row[prefix .. "_cid"] + local indexed_at = row[prefix .. "_indexed_at"] + local did = row[prefix .. "_did"] + local record = row[prefix .. "_record"] + + if type(present) ~= "boolean" then error("reaction presence flag is malformed", 0) end + if not present then + if uri ~= nil or cid ~= nil or indexed_at ~= nil or did ~= nil or record ~= nil then + error("missing reaction contains partial record fields", 0) + end + return ACTOR_REACTION_NULL + end + + local valid, record_collection, record_did = valid_record_uri(uri) + if not valid or record_collection ~= collection or record_did ~= actor + or did ~= actor or not valid_did(did) or type(cid) ~= "string" or #cid == 0 + or (indexed_at ~= nil and type(indexed_at) ~= "string") or type(record) ~= "string" then + error("reaction record fields are malformed", 0) + end + + local ok, view = pcall(record_view, { + uri = uri, + cid = cid, + indexed_at = indexed_at, + did = did, + record = record, + }) + if not ok or type(view) ~= "table" or type(view.record) ~= "table" + or view.record == ACTOR_REACTION_NULL or type(view.record.subject) ~= "table" + or view.record.subject.uri ~= subject then + error("reaction record could not be decoded for its requested subject", 0) + end + return view +end + +local function feed_actor_reaction_results(rows, actor, subjects) + if #rows ~= #subjects then error("query result count does not match requested subjects", 0) end + + local by_ordinal = {} + for _, row in ipairs(rows) do + if type(row) ~= "table" or type(row.request_ordinal) ~= "number" + or row.request_ordinal % 1 ~= 0 or row.request_ordinal < 1 + or row.request_ordinal > #subjects or by_ordinal[row.request_ordinal] ~= nil + or row.requested_subject ~= subjects[row.request_ordinal] then + error("query returned a malformed subject result", 0) + end + + local subject = subjects[row.request_ordinal] + by_ordinal[row.request_ordinal] = { + subject = subject, + like = feed_actor_reaction_record_view(row, "like", ACTOR_REACTION_COLLECTIONS.like, actor, subject), + repost = feed_actor_reaction_record_view(row, "repost", ACTOR_REACTION_COLLECTIONS.repost, actor, subject), + } + end + + local results = {} + for ordinal = 1, #subjects do + if by_ordinal[ordinal] == nil then error("query omitted a requested subject result", 0) end + results[ordinal] = by_ordinal[ordinal] + end + return { results = toarray(results) } +end + +local function feed_actor_reactions() + keys_only(params, { actor = true, subjects = true }) + local actor = scalar(params, "actor") + if not actor or not valid_did(actor) then invalid("actor must be a valid DID") end + local subjects = feed_actor_reaction_subjects(params.subjects) + local rows = feed_actor_reactions_query(actor, subjects) + local ok, response = pcall(feed_actor_reaction_results, rows, actor, subjects) + if not ok then + error("ActorReactionsQueryFailed: indexed actor reactions returned malformed data", 0) + end + return response +end + +function handle() + return feed_actor_reactions() +end diff --git a/api/lua/shared/feedActorReactions.lua b/api/lua/shared/feedActorReactions.lua new file mode 100644 index 0000000..9e27def --- /dev/null +++ b/api/lua/shared/feedActorReactions.lua @@ -0,0 +1,195 @@ +local ACTOR_REACTION_NULL = json.decode("null") +local ACTOR_REACTION_LIMIT = 100 +local ACTOR_REACTION_COLLECTIONS = { + like = "app.certified.feed.like", + repost = "app.certified.feed.repost", +} + +local function actor_reaction_sort_key() + -- Keep representative selection aligned with feedReaction.lua's validated createdAt fallback. + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" +end + +local function valid_actor_reaction_nsid(value) + if type(value) ~= "string" or #value > 317 then return false end + local segments = {} + for segment in value:gmatch("[^%.]+") do segments[#segments + 1] = segment end + if #segments < 3 or table.concat(segments, ".") ~= value then return false end + for index = 1, #segments - 1 do + local segment = segments[index] + if #segment > 63 or segment:find("[^A-Za-z0-9%-]") then return false end + local first, last = segment:sub(1, 1), segment:sub(-1) + if not first:match(index == 1 and "^[A-Za-z]$" or "^[A-Za-z0-9]$") + or not last:match("^[A-Za-z0-9]$") then return false end + end + local name = segments[#segments] + return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil +end + +local function valid_actor_reaction_subject_uri(value) + local valid, collection = valid_record_uri(value) + return valid and valid_actor_reaction_nsid(collection) +end + +local function feed_actor_reaction_subjects(value) + if value == nil then invalid("subjects is required") end + + local subjects = {} + if type(value) == "string" then + subjects[1] = value + elseif type(value) == "table" then + local occurrences = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid("subjects must use repeated query values") + end + occurrences = occurrences + 1 + end + if occurrences ~= #value then invalid("subjects must use repeated query values") end + for index = 1, occurrences do + if type(value[index]) ~= "string" then invalid("subjects entries must be strings") end + subjects[#subjects + 1] = value[index] + end + else + invalid("subjects must be a string or repeated string parameter") + end + + if #subjects < 1 or #subjects > ACTOR_REACTION_LIMIT then + invalid("subjects must contain 1 through 100 AT-URIs") + end + for _, subject in ipairs(subjects) do + if #subject > 8192 or not valid_actor_reaction_subject_uri(subject) then + invalid("each subjects value must be a full DID-authority AT-URI with a valid collection NSID") + end + end + return subjects +end + +local function feed_actor_reactions_query(actor, subjects) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ActorReactionsQueryFailed: actor reaction queries require PostgreSQL", 0) + end + + local values, requested_rows = { actor }, {} + for ordinal, subject in ipairs(subjects) do + values[#values + 1] = subject + requested_rows[#requested_rows + 1] = "($" .. #values .. ", " .. ordinal .. ")" + end + + local likes = ACTOR_REACTION_COLLECTIONS.like + local reposts = ACTOR_REACTION_COLLECTIONS.repost + local sort_key = actor_reaction_sort_key() + local sql = "WITH requested(requested_subject, request_ordinal) AS (VALUES " .. table.concat(requested_rows, ", ") .. "), " .. + "requested_unique AS (SELECT DISTINCT requested_subject FROM requested), " .. + "ranked_reactions AS (SELECT reaction.collection, reaction.record::jsonb->'subject'->>'uri' AS subject_uri, " .. + "reaction.uri, reaction.did, reaction.cid, reaction.indexed_at::text AS indexed_at, reaction.record::text AS record, " .. + "ROW_NUMBER() OVER (PARTITION BY reaction.collection, reaction.record::jsonb->'subject'->>'uri' " .. + "ORDER BY sorted.sort_at ASC, reaction.uri ASC) AS reaction_rank " .. + "FROM happyview_records reaction JOIN requested_unique requested " .. + "ON requested.requested_subject = reaction.record::jsonb->'subject'->>'uri' " .. + "CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) sorted " .. + "WHERE reaction.did = $1 AND reaction.collection IN ('" .. likes .. "', '" .. reposts .. "')), " .. + "representatives AS (SELECT collection, subject_uri, uri, did, cid, indexed_at, record " .. + "FROM ranked_reactions WHERE reaction_rank = 1) " .. + "SELECT requested.request_ordinal, requested.requested_subject, " .. + "(likes.uri IS NOT NULL) AS like_present, likes.uri AS like_uri, likes.cid AS like_cid, " .. + "likes.indexed_at AS like_indexed_at, likes.did AS like_did, likes.record AS like_record, " .. + "(reposts.uri IS NOT NULL) AS repost_present, reposts.uri AS repost_uri, reposts.cid AS repost_cid, " .. + "reposts.indexed_at AS repost_indexed_at, reposts.did AS repost_did, reposts.record AS repost_record " .. + "FROM requested " .. + "LEFT JOIN representatives likes ON likes.collection = '" .. likes .. "' " .. + "AND likes.subject_uri = requested.requested_subject " .. + "LEFT JOIN representatives reposts ON reposts.collection = '" .. reposts .. "' " .. + "AND reposts.subject_uri = requested.requested_subject " .. + "ORDER BY requested.request_ordinal" + + local ok, rows = pcall(db.raw, sql, values) + if not ok or type(rows) ~= "table" then + error("ActorReactionsQueryFailed: indexed actor reactions could not be queried", 0) + end + return rows +end + +local function feed_actor_reaction_record_view(row, prefix, collection, actor, subject) + local present = row[prefix .. "_present"] + local uri = row[prefix .. "_uri"] + local cid = row[prefix .. "_cid"] + local indexed_at = row[prefix .. "_indexed_at"] + local did = row[prefix .. "_did"] + local record = row[prefix .. "_record"] + + if type(present) ~= "boolean" then error("reaction presence flag is malformed", 0) end + if not present then + if uri ~= nil or cid ~= nil or indexed_at ~= nil or did ~= nil or record ~= nil then + error("missing reaction contains partial record fields", 0) + end + return ACTOR_REACTION_NULL + end + + local valid, record_collection, record_did = valid_record_uri(uri) + if not valid or record_collection ~= collection or record_did ~= actor + or did ~= actor or not valid_did(did) or type(cid) ~= "string" or #cid == 0 + or (indexed_at ~= nil and type(indexed_at) ~= "string") or type(record) ~= "string" then + error("reaction record fields are malformed", 0) + end + + local ok, view = pcall(record_view, { + uri = uri, + cid = cid, + indexed_at = indexed_at, + did = did, + record = record, + }) + if not ok or type(view) ~= "table" or type(view.record) ~= "table" + or view.record == ACTOR_REACTION_NULL or type(view.record.subject) ~= "table" + or view.record.subject.uri ~= subject then + error("reaction record could not be decoded for its requested subject", 0) + end + return view +end + +local function feed_actor_reaction_results(rows, actor, subjects) + if #rows ~= #subjects then error("query result count does not match requested subjects", 0) end + + local by_ordinal = {} + for _, row in ipairs(rows) do + if type(row) ~= "table" or type(row.request_ordinal) ~= "number" + or row.request_ordinal % 1 ~= 0 or row.request_ordinal < 1 + or row.request_ordinal > #subjects or by_ordinal[row.request_ordinal] ~= nil + or row.requested_subject ~= subjects[row.request_ordinal] then + error("query returned a malformed subject result", 0) + end + + local subject = subjects[row.request_ordinal] + by_ordinal[row.request_ordinal] = { + subject = subject, + like = feed_actor_reaction_record_view(row, "like", ACTOR_REACTION_COLLECTIONS.like, actor, subject), + repost = feed_actor_reaction_record_view(row, "repost", ACTOR_REACTION_COLLECTIONS.repost, actor, subject), + } + end + + local results = {} + for ordinal = 1, #subjects do + if by_ordinal[ordinal] == nil then error("query omitted a requested subject result", 0) end + results[ordinal] = by_ordinal[ordinal] + end + return { results = toarray(results) } +end + +local function feed_actor_reactions() + keys_only(params, { actor = true, subjects = true }) + local actor = scalar(params, "actor") + if not actor or not valid_did(actor) then invalid("actor must be a valid DID") end + local subjects = feed_actor_reaction_subjects(params.subjects) + local rows = feed_actor_reactions_query(actor, subjects) + local ok, response = pcall(feed_actor_reaction_results, rows, actor, subjects) + if not ok then + error("ActorReactionsQueryFailed: indexed actor reactions returned malformed data", 0) + end + return response +end diff --git a/api/lua/src/getActorReactions.lua b/api/lua/src/getActorReactions.lua new file mode 100644 index 0000000..fa2c985 --- /dev/null +++ b/api/lua/src/getActorReactions.lua @@ -0,0 +1,3 @@ +function handle() + return feed_actor_reactions() +end diff --git a/api/manifest.json b/api/manifest.json index a4addc9..50a437d 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -70,6 +70,10 @@ "id": "app.certified.feed.getActorReposts", "path": "lexicons/app.certified.feed.getActorReposts.json" }, + { + "id": "app.certified.feed.getActorReactions", + "path": "lexicons/app.certified.feed.getActorReactions.json" + }, { "id": "app.certified.actor.profile", "packagePath": "lexicons/app/certified/actor/profile.json" diff --git a/api/modules/feed-reactions/manifest.json b/api/modules/feed-reactions/manifest.json index 294a7ff..3a1cecd 100644 --- a/api/modules/feed-reactions/manifest.json +++ b/api/modules/feed-reactions/manifest.json @@ -42,6 +42,13 @@ "config": { "backfill": false, "target_collection": "app.certified.feed.repost" }, "dependsOn": ["app.certified.feed.getReposts"] }, + { + "kind": "lexicon", + "id": "app.certified.feed.getActorReactions", + "path": "../../lexicons/app.certified.feed.getActorReactions.json", + "config": { "backfill": false }, + "dependsOn": ["app.certified.feed.getLikes", "app.certified.feed.getReposts"] + }, { "kind": "script", "id": "xrpc.query:app.certified.feed.getLikes", @@ -117,6 +124,21 @@ ], "config": { "script_type": "lua", "description": "Feed reaction API handler" }, "dependsOn": ["app.certified.feed.getActorReposts"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.feed.getActorReactions", + "path": "../../lua/endpoints/getActorReactions.lua", + "sourcePath": "../../lua/src/getActorReactions.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/feedActorReactions.lua" + ], + "config": { "script_type": "lua", "description": "Actor feed reaction API handler" }, + "dependsOn": ["app.certified.feed.getActorReactions"] } ] } diff --git a/api/tests/http/feed-reactions.http.test.js b/api/tests/http/feed-reactions.http.test.js index cec2afb..7bbf47c 100644 --- a/api/tests/http/feed-reactions.http.test.js +++ b/api/tests/http/feed-reactions.http.test.js @@ -7,6 +7,7 @@ const likesEndpoint = 'app.certified.feed.getLikes'; const repostsEndpoint = 'app.certified.feed.getReposts'; const actorLikesEndpoint = 'app.certified.feed.getActorLikes'; const actorRepostsEndpoint = 'app.certified.feed.getActorReposts'; +const actorReactionsEndpoint = 'app.certified.feed.getActorReactions'; const likeCollection = 'app.certified.feed.like'; const repostCollection = 'app.certified.feed.repost'; @@ -171,6 +172,35 @@ test('actor reaction queries deduplicate subject URIs and return raw records wit } }); +test('getActorReactions preserves requested occurrences and returns raw reaction views or null', async () => { + const missingSubject = `at://${reactionDids.target}/org.example.unindexed/missing`; + const subjects = [reactionUris.otherSubject, reactionUris.subject, missingSubject, reactionUris.subject]; + const { response, body } = await request(actorReactionsEndpoint, { + actor: reactionDids.actorA, + subjects, + }); + + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.results, [ + { + subject: reactionUris.otherSubject, + like: recordView(fixtureRow(`at://${reactionDids.actorA}/${likeCollection}/3jzfcijpj2z2f`)), + repost: recordView(fixtureRow(`at://${reactionDids.actorA}/${repostCollection}/3jzfcijpj2z2e`)), + }, + { + subject: reactionUris.subject, + like: recordView(fixtureRow(`at://${reactionDids.actorA}/${likeCollection}/3jzfcijpj2z2a`)), + repost: recordView(fixtureRow(`at://${reactionDids.actorA}/${repostCollection}/3jzfcijpj2z2a`)), + }, + { subject: missingSubject, like: null, repost: null }, + { + subject: reactionUris.subject, + like: recordView(fixtureRow(`at://${reactionDids.actorA}/${likeCollection}/3jzfcijpj2z2a`)), + repost: recordView(fixtureRow(`at://${reactionDids.actorA}/${repostCollection}/3jzfcijpj2z2a`)), + }, + ]); +}); + test('actor reaction queries keyset-page URI ties in descending order without hydration', async () => { for (const { endpoint, collection, outputKey, otherKey } of [ { endpoint: actorLikesEndpoint, collection: likeCollection, outputKey: 'likes', otherKey: '3jzfcijpj2z2f' }, diff --git a/api/tests/unit/tooling/feed-reactions.test.js b/api/tests/unit/tooling/feed-reactions.test.js index 0ede940..21f7537 100644 --- a/api/tests/unit/tooling/feed-reactions.test.js +++ b/api/tests/unit/tooling/feed-reactions.test.js @@ -88,11 +88,17 @@ function runLua({ expectError, expectedCalls, assertions, + queryResult, }) { const endpointPath = `lua/endpoints/${endpoint}.lua`; + const queryResultLua = queryResult === undefined ? 'rows' : lua(queryResult); assert.ok(existsSync(`${root}/${endpointPath}`), `${endpointPath} must be generated before exercising the handler`); const records = Object.fromEntries([ - ...rows.filter(({ recordValue }) => recordValue).map(({ record, recordValue }) => [record, recordValue]), + ...rows.flatMap((row) => [ + ...(row.recordValue ? [[row.record, row.recordValue]] : []), + ...(row.likeRecordValue ? [[row.like_record, row.likeRecordValue]] : []), + ...(row.repostRecordValue ? [[row.repost_record, row.repostRecordValue]] : []), + ]), ...profiles.map(({ record, recordValue }) => [record, recordValue]), ...organizations.map(({ record, recordValue }) => [record, recordValue]), ]); @@ -133,6 +139,8 @@ db = { if ${failure === 'repost' ? 'true' : 'false'} and values[1] == '${collections.Repost}' then error('injected query failure') end if ${failure === 'profile' ? 'true' : 'false'} and values[1] == '${profileCollection}' then error('injected profile failure') end if ${failure === 'organization' ? 'true' : 'false'} and values[1] == '${organizationCollection}' then error('injected organization failure') end + if ${failure === 'actorReactions' ? 'true' : 'false'} and '${endpoint}' == 'getActorReactions' then error('injected actor reactions failure') end + if '${endpoint}' == 'getActorReactions' then return ${queryResultLua} end if values[1] == '${collections.Like}' or values[1] == '${collections.Repost}' then return rows end if values[1] == '${profileCollection}' then return profiles end if values[1] == '${organizationCollection}' then return organizations end @@ -251,6 +259,110 @@ assert(calls[1].values[3] == 26, 'limit defaults to 25 and fetches one lookahead }); }); +function actorReactionResultRow(ordinal, requestedSubject, like, repost) { + const row = { request_ordinal: ordinal, requested_subject: requestedSubject }; + for (const [kind, reaction] of [['like', like], ['repost', repost]]) { + row[`${kind}_present`] = reaction !== null; + if (reaction === null) continue; + row[`${kind}_uri`] = reaction.uri; + row[`${kind}_cid`] = reaction.cid; + row[`${kind}_indexed_at`] = reaction.indexed_at; + row[`${kind}_did`] = reaction.did; + row[`${kind}_record`] = reaction.record; + row[`${kind}RecordValue`] = reaction.recordValue; + } + return row; +} + +test('getActorReactions keeps subject occurrences ordered and returns raw like/repost views in one parameterized query', () => { + const firstSubject = 'at://did:plc:cccccccccccccccccccccccc/org.example.subject/first'; + const secondSubject = 'at://did:plc:cccccccccccccccccccccccc/org.example.subject/second'; + const like = reactionRow('Like', actor, 'batch-like', '2025-01-03T00:00:00Z', { + subject: { uri: firstSubject, cid: 'bafy-like-subject-version' }, + via: { uri: 'at://did:plc:dddddddddddddddddddddddd/app.certified.feed.repost/via', cid: 'bafy-via' }, + }); + const repost = reactionRow('Repost', actor, 'batch-repost', '2025-01-02T00:00:00Z', { + subject: { uri: firstSubject, cid: 'bafy-repost-subject-version' }, + }); + runLua({ + endpoint: 'getActorReactions', + params: { actor, subjects: [firstSubject, secondSubject, firstSubject] }, + rows: [ + actorReactionResultRow(3, firstSubject, like, repost), + actorReactionResultRow(2, secondSubject, null, null), + actorReactionResultRow(1, firstSubject, like, repost), + ], + assertions: ` +assert(#result.results == 3 and #calls == 1) +assert(result.results[1].subject == '${firstSubject}') +assert(result.results[2].subject == '${secondSubject}') +assert(result.results[3].subject == '${firstSubject}', 'duplicate subject occurrences retain their position') +assert(result.results[1].like.uri == '${like.uri}' and result.results[1].like.cid == '${like.cid}') +assert(result.results[1].repost.uri == '${repost.uri}' and result.results[1].repost.cid == '${repost.cid}') +assert(result.results[1].like.did == '${actor}' and result.results[1].like.indexedAt == '${indexedAt}') +assert(result.results[1].like.record.subject.cid == 'bafy-like-subject-version') +assert(result.results[1].like.record.via.uri == 'at://did:plc:dddddddddddddddddddddddd/app.certified.feed.repost/via') +assert(result.results[3].like.uri == result.results[1].like.uri and result.results[3].repost.uri == result.results[1].repost.uri) +assert(result.results[2].like == NULL_VALUE and result.results[2].repost == NULL_VALUE, 'successful absence is explicit null') +local sql = calls[1].sql +assert(sql:find('ROW_NUMBER() OVER', 1, true) ~= nil) +local ranking = sql:match('ROW_NUMBER%(%) OVER %((.-)%) AS reaction_rank') +assert(ranking and ranking:find('PARTITION BY reaction.collection', 1, true) ~= nil) +assert(ranking:find('ORDER BY sorted.sort_at ASC, reaction.uri ASC', 1, true) ~= nil) +assert(sql:find('COALESCE(indexed_at::timestamptz, created_at::timestamptz)', 1, true) ~= nil) +assert(sql:find('${collections.Like}', 1, true) ~= nil and sql:find('${collections.Repost}', 1, true) ~= nil) +assert(sql:find('request_ordinal', 1, true) ~= nil and sql:find('LEFT JOIN', 1, true) ~= nil) +assert(sql:find('did = $1', 1, true) ~= nil, 'actor identity is a bound query parameter') +assert(sql:find('${actor}', 1, true) == nil and sql:find('${firstSubject}', 1, true) == nil and sql:find('${secondSubject}', 1, true) == nil) +assert(#calls[1].values == 4 and calls[1].values[1] == '${actor}') +assert(calls[1].values[2] == '${firstSubject}' and calls[1].values[3] == '${secondSubject}' and calls[1].values[4] == '${firstSubject}') +`, + }); +}); + +test('getActorReactions accepts 100 subject occurrences but rejects larger batches before querying', () => { + const subjects = Array.from({ length: 100 }, (_, index) => + `at://did:plc:cccccccccccccccccccccccc/org.example.subject/${index + 1}`); + const emptyRows = subjects.map((subjectUri, index) => actorReactionResultRow(index + 1, subjectUri, null, null)); + runLua({ + endpoint: 'getActorReactions', + params: { actor, subjects }, + rows: emptyRows, + assertions: `assert(#result.results == 100 and #calls == 1 and #calls[1].values == 101)`, + }); + runLua({ + endpoint: 'getActorReactions', + params: { actor, subjects: [...subjects, subjects[0]] }, + expectError: 'InvalidRequest:', + expectedCalls: 0, + }); +}); + +test('getActorReactions rejects malformed query results and database failures instead of returning nulls', () => { + const invalidResponses = [ + { rows: [] }, + { rows: [{ request_ordinal: 1, requested_subject: subject }] }, + { rows: [{ request_ordinal: 1, requested_subject: subject, like_present: true, like_uri: `at://${actor}/${collections.Like}/partial` }] }, + { rows: [], queryResult: false }, + ]; + for (const response of invalidResponses) { + runLua({ + endpoint: 'getActorReactions', + params: { actor, subjects: [subject] }, + ...response, + expectError: 'ActorReactionsQueryFailed:', + expectedCalls: 1, + }); + } + runLua({ + endpoint: 'getActorReactions', + params: { actor, subjects: [subject] }, + failure: 'actorReactions', + expectError: 'ActorReactionsQueryFailed:', + expectedCalls: 1, + }); +}); + test('reaction requests reject invalid identifiers, repeated/unknown parameters, and malformed or mismatched cursors before querying', () => { const cases = [ { endpoint: 'getLikes', params: { subject: 'alice.example' } }, @@ -264,6 +376,15 @@ test('reaction requests reject invalid identifiers, repeated/unknown parameters, { endpoint: 'getActorReposts', params: { actor, cursor: 'not-hex' } }, { endpoint: 'getLikes', params: { subject, cursor: cursor({ direction: 'asc' }), sortDirection: 'desc' } }, { endpoint: 'getReposts', params: { subject, cursor: cursor({ uri: `at://${actor}/${collections.Like}/other` }) } }, + { endpoint: 'getActorReactions', params: { subjects: [subject] } }, + { endpoint: 'getActorReactions', params: { actor } }, + { endpoint: 'getActorReactions', params: { actor, subjects: [] } }, + { endpoint: 'getActorReactions', params: { actor: 'alice.example', subjects: [subject] } }, + { endpoint: 'getActorReactions', params: { actor, subjects: ['at://alice.example/org.example.subject/key'] } }, + { endpoint: 'getActorReactions', params: { actor, subjects: ['at://did:plc:cccccccccccccccccccccccc/bad%20collection/key'] } }, + { endpoint: 'getActorReactions', params: { actor, subjects: [subject], unexpected: 'value' } }, + { endpoint: 'getActorReactions', params: { actor, subjects: { malformed: true } } }, + { endpoint: 'getActorReactions', params: { actor, subjects: Array.from({ length: 101 }, () => subject) } }, ]; for (const { endpoint, params } of cases) { runLua({ endpoint, params, expectError: 'InvalidRequest:', expectedCalls: 0 }); diff --git a/docs/coverage.json b/docs/coverage.json index 8813af5..9969a6f 100644 --- a/docs/coverage.json +++ b/docs/coverage.json @@ -5,12 +5,12 @@ }, "runtimeValidation": "not-assessed", "deploymentValidation": "not-assessed", - "endpointCount": 61, + "endpointCount": 62, "methods": { - "GET": 61 + "GET": 62 }, "inclusion": { - "manifest-registered": 61 + "manifest-registered": 62 }, "unresolvedReferences": [], "endpoints": [ @@ -110,6 +110,12 @@ "inclusion": "manifest-registered", "module": "modules/feed-reactions/manifest.json" }, + { + "id": "app.certified.feed.getActorReactions", + "method": "GET", + "inclusion": "manifest-registered", + "module": "modules/feed-reactions/manifest.json" + }, { "id": "app.certified.feed.getActorReposts", "method": "GET", diff --git a/docs/openapi.json b/docs/openapi.json index 8b31468..8f4c22e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1346,6 +1346,71 @@ ] } }, + "/xrpc/app.certified.feed.getActorReactions": { + "get": { + "operationId": "app_certified_feed_getActorReactions", + "tags": [ + "app.certified.feed" + ], + "description": "Returns the indexed like and repost for each supplied subject occurrence by one actor. Authentication is not required. Subject matching ignores CID; duplicate inputs and request order are preserved. For each reaction collection, duplicate actor and subject URI pairs use the earliest valid createdAt; when it is absent or invalid, ordering falls back to indexedAt and then the database row's created_at timestamp, with reaction URI ascending on ties. A missing indexed reaction is null; query failures return an error.", + "parameters": [ + { + "name": "actor", + "in": "query", + "required": true, + "description": "Repository-owner DID of the actor whose reactions to look up.", + "schema": { + "type": "string", + "format": "did" + } + }, + { + "name": "subjects", + "in": "query", + "required": true, + "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order.", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "format": "at-uri" + } + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app.certified.feed.getActorReactions.output" + } + } + } + } + }, + "x-lexicon-id": "app.certified.feed.getActorReactions", + "x-hypercerts-coverage": "manifest-registered", + "x-hypercerts-source": { + "module": "modules/feed-reactions/manifest.json" + }, + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The actor DID, subjects array, subject AT-URI, or another query parameter is invalid." + }, + { + "name": "ActorReactionsQueryFailed", + "description": "The indexed actor reactions could not be queried or returned a malformed result; retry the request." + } + ] + } + }, "/xrpc/app.certified.feed.getActorReposts": { "get": { "operationId": "app_certified_feed_getActorReposts", @@ -5852,6 +5917,56 @@ } } }, + "app.certified.feed.getActorReactions.actorReactionResult": { + "type": "object", + "required": [ + "subject", + "like", + "repost" + ], + "properties": { + "subject": { + "type": "string", + "description": "The subject AT-URI from this request occurrence; matching ignores CID.", + "format": "at-uri" + }, + "like": { + "anyOf": [ + { + "$ref": "#/components/schemas/app.certified.feed.getLikes.likeRecordView" + }, + { + "type": "null" + } + ] + }, + "repost": { + "anyOf": [ + { + "$ref": "#/components/schemas/app.certified.feed.getReposts.repostRecordView" + }, + { + "type": "null" + } + ] + } + } + }, + "app.certified.feed.getActorReactions.output": { + "type": "object", + "required": [ + "results" + ], + "properties": { + "results": { + "type": "array", + "description": "One result for each supplied subject occurrence, in request order, including duplicates.", + "items": { + "$ref": "#/components/schemas/app.certified.feed.getActorReactions.actorReactionResult" + } + } + } + }, "app.certified.feed.getActorReposts.output": { "type": "object", "required": [ diff --git a/docs/sources/index.json b/docs/sources/index.json index 4a3377a..0e66198 100644 --- a/docs/sources/index.json +++ b/docs/sources/index.json @@ -149,6 +149,15 @@ "path": "../../lexicons/app.certified.feed.getActorLikes.json" } }, + { + "id": "app.certified.feed.getActorReactions", + "type": "query", + "module": "modules/feed-reactions/manifest.json", + "file": "sources/lexicons/app.certified.feed.getActorReactions.json", + "source": { + "path": "../../lexicons/app.certified.feed.getActorReactions.json" + } + }, { "id": "app.certified.feed.getActorReposts", "type": "query", @@ -756,6 +765,15 @@ "path": "../../lexicons/app.certified.feed.getActorLikes.json" } }, + { + "id": "app.certified.feed.getActorReactions", + "file": "sources/lexicons/app.certified.feed.getActorReactions.json", + "kind": "endpoint", + "source": { + "module": "modules/feed-reactions/manifest.json", + "path": "../../lexicons/app.certified.feed.getActorReactions.json" + } + }, { "id": "app.certified.feed.getActorReposts", "file": "sources/lexicons/app.certified.feed.getActorReposts.json", diff --git a/docs/sources/lexicons/app.certified.feed.getActorReactions.json b/docs/sources/lexicons/app.certified.feed.getActorReactions.json new file mode 100644 index 0000000..99c8460 --- /dev/null +++ b/docs/sources/lexicons/app.certified.feed.getActorReactions.json @@ -0,0 +1,96 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getActorReactions", + "defs": { + "main": { + "type": "query", + "description": "Returns the indexed like and repost for each supplied subject occurrence by one actor. Authentication is not required. Subject matching ignores CID; duplicate inputs and request order are preserved. For each reaction collection, duplicate actor and subject URI pairs use the earliest valid createdAt; when it is absent or invalid, ordering falls back to indexedAt and then the database row's created_at timestamp, with reaction URI ascending on ties. A missing indexed reaction is null; query failures return an error.", + "parameters": { + "type": "params", + "required": [ + "actor", + "subjects" + ], + "properties": { + "actor": { + "type": "string", + "format": "did", + "description": "Repository-owner DID of the actor whose reactions to look up." + }, + "subjects": { + "type": "array", + "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order.", + "minLength": 1, + "maxLength": 100, + "items": { + "type": "string", + "format": "at-uri" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The actor DID, subjects array, subject AT-URI, or another query parameter is invalid." + }, + { + "name": "ActorReactionsQueryFailed", + "description": "The indexed actor reactions could not be queried or returned a malformed result; retry the request." + } + ] + }, + "output": { + "type": "object", + "required": [ + "results" + ], + "properties": { + "results": { + "type": "array", + "description": "One result for each supplied subject occurrence, in request order, including duplicates.", + "items": { + "type": "ref", + "ref": "#actorReactionResult" + } + } + } + }, + "actorReactionResult": { + "type": "object", + "required": [ + "subject", + "like", + "repost" + ], + "nullable": [ + "like", + "repost" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "The subject AT-URI from this request occurrence; matching ignores CID." + }, + "like": { + "type": "ref", + "ref": "app.certified.feed.getLikes#likeRecordView", + "description": "The actor's earliest indexed like for this subject URI, or null when none is indexed." + }, + "repost": { + "type": "ref", + "ref": "app.certified.feed.getReposts#repostRecordView", + "description": "The actor's earliest indexed repost for this subject URI, or null when none is indexed." + } + } + } + } +}