diff --git a/.github/workflows/build-and-push-bsky-aws.yaml b/.github/workflows/build-and-push-bsky-aws.yaml index f534e015ea5..36b1aa23cb3 100644 --- a/.github/workflows/build-and-push-bsky-aws.yaml +++ b/.github/workflows/build-and-push-bsky-aws.yaml @@ -3,7 +3,6 @@ on: push: branches: - main - - appview-v1-courier env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/.github/workflows/build-and-push-bsky-ghcr.yaml b/.github/workflows/build-and-push-bsky-ghcr.yaml index 5d22cd9a389..f1bf0bd10f5 100644 --- a/.github/workflows/build-and-push-bsky-ghcr.yaml +++ b/.github/workflows/build-and-push-bsky-ghcr.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - appview-v2 env: REGISTRY: ghcr.io USERNAME: ${{ github.actor }} diff --git a/.prettierignore b/.prettierignore index 6340cc359c2..7bb137f13ea 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ pnpm-lock.yaml .pnpm* .changeset *.d.ts +packages/bsky/src/data-plane/gen \ No newline at end of file diff --git a/packages/bsky/bin/migration-create.ts b/packages/bsky/bin/migration-create.ts index b51c536c4f2..27edc5e0979 100644 --- a/packages/bsky/bin/migration-create.ts +++ b/packages/bsky/bin/migration-create.ts @@ -14,7 +14,15 @@ export async function main() { ) } const filename = `${prefix}-${name}` - const dir = path.join(__dirname, '..', 'src', 'db', 'migrations') + const dir = path.join( + __dirname, + '..', + 'src', + 'data-plane', + 'server', + 'db', + 'migrations', + ) await fs.writeFile(path.join(dir, `${filename}.ts`), template, { flag: 'wx' }) await fs.writeFile( diff --git a/packages/bsky/build.js b/packages/bsky/build.js index 3822d9bc98f..85c4a88243b 100644 --- a/packages/bsky/build.js +++ b/packages/bsky/build.js @@ -5,7 +5,7 @@ const buildShallow = require('esbuild').build({ logLevel: 'info', - entryPoints: ['src/index.ts', 'src/db/index.ts'], + entryPoints: ['src/index.ts'], bundle: true, sourcemap: true, outdir: 'dist', diff --git a/packages/bsky/package.json b/packages/bsky/package.json index ec6f8f1feb3..099501296ed 100644 --- a/packages/bsky/package.json +++ b/packages/bsky/package.json @@ -42,6 +42,7 @@ "@atproto/xrpc-server": "workspace:^", "@bufbuild/protobuf": "^1.5.0", "@connectrpc/connect": "^1.1.4", + "@connectrpc/connect-express": "^1.1.4", "@connectrpc/connect-node": "^1.1.4", "@did-plc/lib": "^0.0.1", "@isaacs/ttlcache": "^1.4.1", @@ -80,6 +81,7 @@ "@types/express-serve-static-core": "^4.17.36", "@types/pg": "^8.6.6", "@types/qs": "^6.9.7", - "axios": "^0.27.2" + "axios": "^0.27.2", + "http2-express-bridge": "^1.0.7" } } diff --git a/packages/bsky/proto/bsky.proto b/packages/bsky/proto/bsky.proto new file mode 100644 index 00000000000..ffff8efa043 --- /dev/null +++ b/packages/bsky/proto/bsky.proto @@ -0,0 +1,1164 @@ +syntax = "proto3"; + +package bsky; +option go_package = "./;bsky"; + +import "google/protobuf/timestamp.proto"; + +// +// Read Path +// + +message Record { + bytes record = 1; + string cid = 2; + google.protobuf.Timestamp indexed_at = 4; + bool taken_down = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp sorted_at = 7; + string takedown_ref = 8; +} + +message GetBlockRecordsRequest { + repeated string uris = 1; +} + +message GetBlockRecordsResponse { + repeated Record records = 1; +} + +message GetFeedGeneratorRecordsRequest { + repeated string uris = 1; +} + +message GetFeedGeneratorRecordsResponse { + repeated Record records = 1; +} + +message GetFollowRecordsRequest { + repeated string uris = 1; +} + +message GetFollowRecordsResponse { + repeated Record records = 1; +} + +message GetLikeRecordsRequest { + repeated string uris = 1; +} + +message GetLikeRecordsResponse { + repeated Record records = 1; +} + +message GetListBlockRecordsRequest { + repeated string uris = 1; +} + +message GetListBlockRecordsResponse { + repeated Record records = 1; +} + +message GetListItemRecordsRequest { + repeated string uris = 1; +} + +message GetListItemRecordsResponse { + repeated Record records = 1; +} + +message GetListRecordsRequest { + repeated string uris = 1; +} + +message GetListRecordsResponse { + repeated Record records = 1; +} + +message PostRecordMeta { + bool violates_thread_gate = 1; + bool has_media = 2; + bool is_reply = 3; +} + +message GetPostRecordsRequest { + repeated string uris = 1; +} + +message GetPostRecordsResponse { + repeated Record records = 1; + repeated PostRecordMeta meta = 2; +} + +message GetProfileRecordsRequest { + repeated string uris = 1; +} + +message GetProfileRecordsResponse { + repeated Record records = 1; +} + +message GetRepostRecordsRequest { + repeated string uris = 1; +} + +message GetRepostRecordsResponse { + repeated Record records = 1; +} + +message GetThreadGateRecordsRequest { + repeated string uris = 1; +} + +message GetThreadGateRecordsResponse { + repeated Record records = 1; +} + + +// +// Follows +// + +// - Return follow uris where user A follows users B, C, D, … +// - E.g. for viewer state on `getProfiles` +message GetActorFollowsActorsRequest { + string actor_did = 1; + repeated string target_dids = 2; +} + +message GetActorFollowsActorsResponse { + repeated string uris = 1; +} + +// - Return follow uris of users who follows user A +// - For `getFollowers` list +message GetFollowersRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message FollowInfo { + string uri = 1; + string actor_did = 2; + string subject_did = 3; +} + +message GetFollowersResponse { + repeated FollowInfo followers = 1; + string cursor = 2; +} + +// - Return follow uris of users A follows +// - For `getFollows` list +message GetFollowsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetFollowsResponse { + repeated FollowInfo follows = 1; + string cursor = 2; +} + +// +// Likes +// + +// - return like uris where subject uri is subject A +// - `getLikes` list for a post +message GetLikesBySubjectRequest { + RecordRef subject = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetLikesBySubjectResponse { + repeated string uris = 1; + string cursor = 2; +} + +// - return like uris for user A on subject B, C, D... +// - viewer state on posts +message GetLikesByActorAndSubjectsRequest { + string actor_did = 1; + repeated RecordRef refs = 2; +} + +message GetLikesByActorAndSubjectsResponse { + repeated string uris = 1; +} + +// - return recent like uris for user A +// - `getActorLikes` list for a user +message GetActorLikesRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message LikeInfo { + string uri = 1; + string subject = 2; +} + +message GetActorLikesResponse { + repeated LikeInfo likes = 1; + string cursor = 2; +} + +// +// Interactions +// +message GetInteractionCountsRequest { + repeated RecordRef refs = 1; +} + +message GetInteractionCountsResponse { + repeated int32 likes = 1; + repeated int32 reposts = 2; + repeated int32 replies = 3; +} + +message GetCountsForUsersRequest { + repeated string dids = 1; +} + +message GetCountsForUsersResponse { + repeated int32 posts = 1; + repeated int32 reposts = 2; + repeated int32 following = 3; + repeated int32 followers = 4; +} + +// +// Reposts +// + +// - return repost uris where subject uri is subject A +// - `getReposts` list for a post +message GetRepostsBySubjectRequest { + RecordRef subject = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetRepostsBySubjectResponse { + repeated string uris = 1; + string cursor = 2; +} + +// - return repost uris for user A on subject B, C, D... +// - viewer state on posts +message GetRepostsByActorAndSubjectsRequest { + string actor_did = 1; + repeated RecordRef refs = 2; +} + +message RecordRef { + string uri = 1; + string cid = 2; +} + +message GetRepostsByActorAndSubjectsResponse { + repeated string uris = 1; +} + +// - return recent repost uris for user A +// - `getActorReposts` list for a user +message GetActorRepostsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetActorRepostsResponse { + repeated string uris = 1; + string cursor = 2; +} + +// +// Profile +// + +// - return actor information for dids A, B, C… +// - profile hydration +// - should this include handles? apply repo takedown? +message GetActorsRequest { + repeated string dids = 1; +} + +message ActorInfo { + bool exists = 1; + string handle = 2; + Record profile = 3; + bool taken_down = 4; + string takedown_ref = 5; + google.protobuf.Timestamp tombstoned_at = 6; +} + +message GetActorsResponse { + repeated ActorInfo actors = 1; +} + +// - return did for handle A +// - `resolveHandle` +// - answering queries where the query param is a handle +message GetDidsByHandlesRequest { + repeated string handles = 1; +} + +message GetDidsByHandlesResponse { + repeated string dids = 1; +} + +// +// Relationships +// + +// - return relationships between user A and users B, C, D... +// - profile hydration +// - block application +message GetRelationshipsRequest { + string actor_did = 1; + repeated string target_dids = 2; +} + +message Relationships { + bool muted = 1; + string muted_by_list = 2; + string blocked_by = 3; + string blocking = 4; + string blocked_by_list = 5; + string blocking_by_list = 6; + string following = 7; + string followed_by = 8; +} + +message GetRelationshipsResponse { + repeated Relationships relationships = 1; +} + +// - return whether a block (bidrectionally and either direct or through a list) exists between two dids +// - enforcing 3rd party block violations +message RelationshipPair { + string a = 1; + string b = 2; +} + +message GetBlockExistenceRequest { + repeated RelationshipPair pairs = 1; +} + +message GetBlockExistenceResponse { + repeated bool exists = 1; +} + + +// +// Lists +// + +message ListItemInfo { + string uri = 1; + string did = 2; +} + +// - Return dids of users in list A +// - E.g. to view items in one of your mute lists +message GetListMembersRequest { + string list_uri = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetListMembersResponse { + repeated ListItemInfo listitems = 1; + string cursor = 2; +} + +// - Return list uris where user A in list B, C, D… +// - Used in thread reply gates +message GetListMembershipRequest { + string actor_did = 1; + repeated string list_uris = 2; +} + +message GetListMembershipResponse { + repeated string listitem_uris = 1; +} + +// - Return number of items in list A +// - For aggregate +message GetListCountRequest { + string list_uri = 1; +} + +message GetListCountResponse { + int32 count = 1; +} + + +// - return list of uris of lists created by A +// - `getLists` +message GetActorListsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetActorListsResponse { + repeated string list_uris = 1; + string cursor = 2; +} + +// +// Mutes +// + +// - return boolean if user A has muted user B +// - hydrating mute state onto profiles +message GetActorMutesActorRequest { + string actor_did = 1; + string target_did = 2; +} + +message GetActorMutesActorResponse { + bool muted = 1; +} + +// - return list of user dids of users who A mutes +// - `getMutes` +message GetMutesRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetMutesResponse { + repeated string dids = 1; + string cursor = 2; +} + +// +// Mutelists +// + +// - return list uri of *any* list through which user A has muted user B +// - hydrating mute state onto profiles +// - note: we only need *one* uri even if a user is muted by multiple lists +message GetActorMutesActorViaListRequest { + string actor_did = 1; + string target_did = 2; +} + +message GetActorMutesActorViaListResponse { + string list_uri = 1; +} + +// - return boolean if actor A has subscribed to mutelist B +// - list view hydration +message GetMutelistSubscriptionRequest { + string actor_did = 1; + string list_uri = 2; +} + +message GetMutelistSubscriptionResponse { + bool subscribed = 1; +} + +// - return list of list uris of mutelists that A subscribes to +// - `getListMutes` +message GetMutelistSubscriptionsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetMutelistSubscriptionsResponse { + repeated string list_uris = 1; + string cursor = 2; +} + +// +// Blocks +// + +// - Return block uri if there is a block between users A & B (bidirectional) +// - hydrating (& actioning) block state on profiles +// - handling 3rd party blocks +message GetBidirectionalBlockRequest { + string actor_did = 1; + string target_did = 2; +} + +message GetBidirectionalBlockResponse { + string block_uri = 1; +} + +// - Return list of block uris and user dids of users who A blocks +// - `getBlocks` +message GetBlocksRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetBlocksResponse { + repeated string block_uris = 1; + string cursor = 2; +} + +// +// Blocklists +// + +// - Return list uri of ***any*** list through which users A & B have a block (bidirectional) +// - hydrating (& actioning) block state on profiles +// - handling 3rd party blocks +message GetBidirectionalBlockViaListRequest { + string actor_did = 1; + string target_did = 2; +} + +message GetBidirectionalBlockViaListResponse { + string list_uri = 1; +} + +// - return boolean if user A has subscribed to blocklist B +// - list view hydration +message GetBlocklistSubscriptionRequest { + string actor_did = 1; + string list_uri = 2; +} + +message GetBlocklistSubscriptionResponse { + string listblock_uri = 1; +} + +// - return list of list uris of Blockslists that A subscribes to +// - `getListBlocks` +message GetBlocklistSubscriptionsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetBlocklistSubscriptionsResponse { + repeated string list_uris = 1; + string cursor = 2; +} + +// +// Notifications +// + +// - list recent notifications for a user +// - notifications should include a uri for the record that caused the notif & a “reason” for the notification (reply, like, quotepost, etc) +// - this should include both read & unread notifs +message GetNotificationsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message Notification { + string recipient_did = 1; + string uri = 2; + string reason = 3; + string reason_subject = 4; + google.protobuf.Timestamp timestamp = 5; +} + +message GetNotificationsResponse { + repeated Notification notifications = 1; + string cursor = 2; +} + +// - update a user’s “last seen time” +// - `updateSeen` +message UpdateNotificationSeenRequest { + string actor_did = 1; + google.protobuf.Timestamp timestamp = 2; +} + +message UpdateNotificationSeenResponse {} + +// - get a user’s “last seen time” +// - hydrating read state onto notifications +message GetNotificationSeenRequest { + string actor_did = 1; +} + +message GetNotificationSeenResponse { + google.protobuf.Timestamp timestamp = 1; +} + +// - get a count of all unread notifications (notifications after `updateSeen`) +// - `getUnreadCount` +message GetUnreadNotificationCountRequest { + string actor_did = 1; +} + +message GetUnreadNotificationCountResponse { + int32 count = 1; +} + +// +// FeedGenerators +// + +// - Return uris of feed generator records created by user A +// - `getActorFeeds` +message GetActorFeedsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetActorFeedsResponse { + repeated string uris = 1; + string cursor = 2; +} + +// - Returns a list of suggested feed generator uris for an actor, paginated +// - `getSuggestedFeeds` +// - This is currently just hardcoded in the Appview DB +message GetSuggestedFeedsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetSuggestedFeedsResponse { + repeated string uris = 1; + string cursor = 2; +} + +message SearchFeedGeneratorsRequest { + string query = 1; + int32 limit = 2; +} + +message SearchFeedGeneratorsResponse { + repeated string uris = 1; +} + +// - Returns feed generator validity and online status with uris A, B, C… +// - Not currently being used, but could be worhthwhile. +message GetFeedGeneratorStatusRequest { + repeated string uris = 1; +} + +message GetFeedGeneratorStatusResponse { + repeated string status = 1; +} + +// +// Feeds +// + +enum FeedType { + FEED_TYPE_UNSPECIFIED = 0; + FEED_TYPE_POSTS_AND_AUTHOR_THREADS = 1; + FEED_TYPE_POSTS_NO_REPLIES = 2; + FEED_TYPE_POSTS_WITH_MEDIA = 3; +} + +// - Returns recent posts authored by a given DID, paginated +// - `getAuthorFeed` +// - Optionally: filter by if a post is/isn’t a reply and if a post has a media object in it +message GetAuthorFeedRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; + FeedType feed_type = 4; +} + +message AuthorFeedItem { + string uri = 1; + string cid = 2; + string repost = 3; + string repost_cid = 4; + bool posts_and_author_threads = 5; + bool posts_no_replies = 6; + bool posts_with_media = 7; + bool is_reply = 8; + bool is_repost = 9; + bool is_quote_post = 10; +} + +message GetAuthorFeedResponse { + repeated AuthorFeedItem items = 1; + string cursor = 2; +} + +// - Returns recent posts authored by users followed by a given DID, paginated +// - `getTimeline` +message GetTimelineRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; + bool exclude_replies = 4; + bool exclude_reposts = 5; + bool exclude_quotes = 6; +} + +message GetTimelineResponse { + repeated TimelineFeedItem items = 1; + string cursor = 2; +} + +message TimelineFeedItem { + string uri = 1; + string cid = 2; + string repost = 3; + string repost_cid = 4; + bool is_reply = 5; + bool is_repost = 6; + bool is_quote_post = 7; +} + +// - Return recent post uris from users in list A +// - `getListFeed` +// - (This is essentially the same as `getTimeline` but instead of follows of a did, it is list items of a list) +message GetListFeedRequest { + string list_uri = 1; + int32 limit = 2; + string cursor = 3; + bool exclude_replies = 4; + bool exclude_reposts = 5; + bool exclude_quotes = 6; +} + +message GetListFeedResponse { + repeated TimelineFeedItem items = 1; + string cursor = 2; +} + +// +// Threads +// + +// Return posts uris of any replies N levels above or M levels below post A +message GetThreadRequest { + string post_uri = 1; + int32 above = 2; + int32 below = 3; +} + +message GetThreadResponse { + repeated string uris = 1; +} + +// +// Search +// + +// - Return DIDs of actors matching term, paginated +// - `searchActors` skeleton +message SearchActorsRequest { + string term = 1; + int32 limit = 2; + string cursor = 3; +} + +message SearchActorsResponse { + repeated string dids = 1; + string cursor = 2; +} + +// - Return uris of posts matching term, paginated +// - `searchPosts` skeleton +message SearchPostsRequest { + string term = 1; + int32 limit = 2; + string cursor = 3; +} + +message SearchPostsResponse { + repeated string uris = 1; + string cursor = 2; +} + +// +// Suggestions +// + +// - Return DIDs of suggested follows for a user, excluding anyone they already follow +// - `getSuggestions`, `getSuggestedFollowsByActor` +message GetFollowSuggestionsRequest { + string actor_did = 1; + string relative_to_did = 2; + int32 limit = 3; + string cursor = 4; +} + +message GetFollowSuggestionsResponse { + repeated string dids = 1; + string cursor = 2; +} + +message SuggestedEntity { + string tag = 1; + string subject = 2; + string subject_type = 3; + int64 priority = 4; +} + +message GetSuggestedEntitiesRequest { + int32 limit = 1; + string cursor = 2; +} + +message GetSuggestedEntitiesResponse { + repeated SuggestedEntity entities = 1; + string cursor = 2; +} + +// +// Posts +// + +// - Return post reply count with uris A, B, C… +// - All feed hydration +message GetPostReplyCountsRequest { + repeated RecordRef refs = 1; +} + +message GetPostReplyCountsResponse { + repeated int32 counts = 1; +} + +// +// Labels +// + +// - Get all labels on a subjects A, B, C (uri or did) issued by dids D, E, F… +// - label hydration on nearly every view +message GetLabelsRequest { + repeated string subjects = 1; + repeated string issuers = 2; +} + +message GetLabelsResponse { + repeated bytes labels = 1; +} + +// +// Sync +// + +// - Latest repo rev of user w/ DID +// - Read-after-write header in`getProfile`, `getProfiles`, `getActorLikes`, `getAuthorFeed`, `getListFeed`, `getPostThread`, `getTimeline`. Could it be view dependent? +message GetLatestRevRequest { + string actor_did = 1; +} + +message GetLatestRevResponse { + string rev = 1; +} + + +message GetIdentityByDidRequest { + string did = 1; +} +message GetIdentityByDidResponse { + string did = 1; + string handle = 2; + bytes keys = 3; + bytes services = 4; + google.protobuf.Timestamp updated = 5; +} + +message GetIdentityByHandleRequest { + string handle = 1; +} +message GetIdentityByHandleResponse { + string handle = 1; + string did = 2; + bytes keys = 3; + bytes services = 4; + google.protobuf.Timestamp updated = 5; +} + + + +// +// Moderation +// + +message GetBlobTakedownRequest { + string did = 1; + string cid = 2; +} + +message GetBlobTakedownResponse { + bool taken_down = 1; + string takedown_ref = 2; +} + + + +message GetActorTakedownRequest { + string did = 1; +} + +message GetActorTakedownResponse { + bool taken_down = 1; + string takedown_ref = 2; +} + +message GetRecordTakedownRequest { + string record_uri = 1; +} + +message GetRecordTakedownResponse { + bool taken_down = 1; + string takedown_ref = 2; +} + + +// Ping +message PingRequest {} +message PingResponse {} + + + + +service Service { + // + // Read Path + // + + // Records + rpc GetBlockRecords(GetBlockRecordsRequest) returns (GetBlockRecordsResponse); + rpc GetFeedGeneratorRecords(GetFeedGeneratorRecordsRequest) returns (GetFeedGeneratorRecordsResponse); + rpc GetFollowRecords(GetFollowRecordsRequest) returns (GetFollowRecordsResponse); + rpc GetLikeRecords(GetLikeRecordsRequest) returns (GetLikeRecordsResponse); + rpc GetListBlockRecords(GetListBlockRecordsRequest) returns (GetListBlockRecordsResponse); + rpc GetListItemRecords(GetListItemRecordsRequest) returns (GetListItemRecordsResponse); + rpc GetListRecords(GetListRecordsRequest) returns (GetListRecordsResponse); + rpc GetPostRecords(GetPostRecordsRequest) returns (GetPostRecordsResponse); + rpc GetProfileRecords(GetProfileRecordsRequest) returns (GetProfileRecordsResponse); + rpc GetRepostRecords(GetRepostRecordsRequest) returns (GetRepostRecordsResponse); + rpc GetThreadGateRecords(GetThreadGateRecordsRequest) returns (GetThreadGateRecordsResponse); + + // Follows + rpc GetActorFollowsActors(GetActorFollowsActorsRequest) returns (GetActorFollowsActorsResponse); + rpc GetFollowers(GetFollowersRequest) returns (GetFollowersResponse); + rpc GetFollows(GetFollowsRequest) returns (GetFollowsResponse); + + // Likes + rpc GetLikesBySubject(GetLikesBySubjectRequest) returns (GetLikesBySubjectResponse); + rpc GetLikesByActorAndSubjects(GetLikesByActorAndSubjectsRequest) returns (GetLikesByActorAndSubjectsResponse); + rpc GetActorLikes(GetActorLikesRequest) returns (GetActorLikesResponse); + + // Reposts + rpc GetRepostsBySubject(GetRepostsBySubjectRequest) returns (GetRepostsBySubjectResponse); + rpc GetRepostsByActorAndSubjects(GetRepostsByActorAndSubjectsRequest) returns (GetRepostsByActorAndSubjectsResponse); + rpc GetActorReposts(GetActorRepostsRequest) returns (GetActorRepostsResponse); + + // Interaction Counts + rpc GetInteractionCounts(GetInteractionCountsRequest) returns (GetInteractionCountsResponse); + rpc GetCountsForUsers(GetCountsForUsersRequest) returns (GetCountsForUsersResponse); + + // Profile + rpc GetActors(GetActorsRequest) returns (GetActorsResponse); + rpc GetDidsByHandles(GetDidsByHandlesRequest) returns (GetDidsByHandlesResponse); + + // Relationships + rpc GetRelationships(GetRelationshipsRequest) returns (GetRelationshipsResponse); + rpc GetBlockExistence(GetBlockExistenceRequest) returns (GetBlockExistenceResponse); + + // Lists + rpc GetActorLists(GetActorListsRequest) returns (GetActorListsResponse); + rpc GetListMembers(GetListMembersRequest) returns (GetListMembersResponse); + rpc GetListMembership(GetListMembershipRequest) returns (GetListMembershipResponse); + rpc GetListCount(GetListCountRequest) returns (GetListCountResponse); + + // Mutes + rpc GetActorMutesActor(GetActorMutesActorRequest) returns (GetActorMutesActorResponse); + rpc GetMutes(GetMutesRequest) returns (GetMutesResponse); + + // Mutelists + rpc GetActorMutesActorViaList(GetActorMutesActorViaListRequest) returns (GetActorMutesActorViaListResponse); + rpc GetMutelistSubscription(GetMutelistSubscriptionRequest) returns (GetMutelistSubscriptionResponse); + rpc GetMutelistSubscriptions(GetMutelistSubscriptionsRequest) returns (GetMutelistSubscriptionsResponse); + + // Blocks + rpc GetBidirectionalBlock(GetBidirectionalBlockRequest) returns (GetBidirectionalBlockResponse); + rpc GetBlocks(GetBlocksRequest) returns (GetBlocksResponse); + + // Blocklists + rpc GetBidirectionalBlockViaList(GetBidirectionalBlockViaListRequest) returns (GetBidirectionalBlockViaListResponse); + rpc GetBlocklistSubscription(GetBlocklistSubscriptionRequest) returns (GetBlocklistSubscriptionResponse); + rpc GetBlocklistSubscriptions(GetBlocklistSubscriptionsRequest) returns (GetBlocklistSubscriptionsResponse); + + // Notifications + rpc GetNotifications(GetNotificationsRequest) returns (GetNotificationsResponse); + rpc GetNotificationSeen(GetNotificationSeenRequest) returns (GetNotificationSeenResponse); + rpc GetUnreadNotificationCount(GetUnreadNotificationCountRequest) returns (GetUnreadNotificationCountResponse); + rpc UpdateNotificationSeen(UpdateNotificationSeenRequest) returns (UpdateNotificationSeenResponse); + + // FeedGenerators + rpc GetActorFeeds(GetActorFeedsRequest) returns (GetActorFeedsResponse); + rpc GetSuggestedFeeds(GetSuggestedFeedsRequest) returns (GetSuggestedFeedsResponse); + rpc GetFeedGeneratorStatus(GetFeedGeneratorStatusRequest) returns (GetFeedGeneratorStatusResponse); + rpc SearchFeedGenerators(SearchFeedGeneratorsRequest) returns (SearchFeedGeneratorsResponse); + + // Feeds + rpc GetAuthorFeed(GetAuthorFeedRequest) returns (GetAuthorFeedResponse); + rpc GetTimeline(GetTimelineRequest) returns (GetTimelineResponse); + rpc GetListFeed(GetListFeedRequest) returns (GetListFeedResponse); + + // Threads + rpc GetThread(GetThreadRequest) returns (GetThreadResponse); + + // Search + rpc SearchActors(SearchActorsRequest) returns (SearchActorsResponse); + rpc SearchPosts(SearchPostsRequest) returns (SearchPostsResponse); + + // Suggestions + rpc GetFollowSuggestions(GetFollowSuggestionsRequest) returns (GetFollowSuggestionsResponse); + rpc GetSuggestedEntities(GetSuggestedEntitiesRequest) returns (GetSuggestedEntitiesResponse); + + // Posts + rpc GetPostReplyCounts(GetPostReplyCountsRequest) returns (GetPostReplyCountsResponse); + + // Labels + rpc GetLabels(GetLabelsRequest) returns (GetLabelsResponse); + + // Sync + rpc GetLatestRev(GetLatestRevRequest) returns (GetLatestRevResponse); + + // Moderation + rpc GetBlobTakedown(GetBlobTakedownRequest) returns (GetBlobTakedownResponse); + rpc GetRecordTakedown(GetRecordTakedownRequest) returns (GetRecordTakedownResponse); + rpc GetActorTakedown(GetActorTakedownRequest) returns (GetActorTakedownResponse); + + // Identity + rpc GetIdentityByDid(GetIdentityByDidRequest) returns (GetIdentityByDidResponse); + rpc GetIdentityByHandle(GetIdentityByHandleRequest) returns (GetIdentityByHandleResponse); + + // Ping + rpc Ping(PingRequest) returns (PingResponse); + + + + + // + // Write Path + // + + // Moderation + rpc TakedownBlob(TakedownBlobRequest) returns (TakedownBlobResponse); + rpc TakedownRecord(TakedownRecordRequest) returns (TakedownRecordResponse); + rpc TakedownActor(TakedownActorRequest) returns (TakedownActorResponse); + + rpc UntakedownBlob(UntakedownBlobRequest) returns (UntakedownBlobResponse); + rpc UntakedownRecord(UntakedownRecordRequest) returns (UntakedownRecordResponse); + rpc UntakedownActor(UntakedownActorRequest) returns (UntakedownActorResponse); + + // Ingestion + rpc CreateActorMute(CreateActorMuteRequest) returns (CreateActorMuteResponse); + rpc DeleteActorMute(DeleteActorMuteRequest) returns (DeleteActorMuteResponse); + rpc ClearActorMutes(ClearActorMutesRequest) returns (ClearActorMutesResponse); + + rpc CreateActorMutelistSubscription(CreateActorMutelistSubscriptionRequest) returns (CreateActorMutelistSubscriptionResponse); + rpc DeleteActorMutelistSubscription(DeleteActorMutelistSubscriptionRequest) returns (DeleteActorMutelistSubscriptionResponse); + rpc ClearActorMutelistSubscriptions(ClearActorMutelistSubscriptionsRequest) returns (ClearActorMutelistSubscriptionsResponse); +} + + +message TakedownActorRequest { + string did = 1; + string ref = 2; + google.protobuf.Timestamp seen = 3; +} + +message TakedownActorResponse { +} + +message UntakedownActorRequest { + string did = 1; + google.protobuf.Timestamp seen = 2; +} + +message UntakedownActorResponse { +} + +message TakedownBlobRequest { + string did = 1; + string cid = 2; + string ref = 3; + google.protobuf.Timestamp seen = 4; +} + +message TakedownBlobResponse {} + +message UntakedownBlobRequest { + string did = 1; + string cid = 2; + google.protobuf.Timestamp seen = 3; +} + +message UntakedownBlobResponse {} + +message TakedownRecordRequest { + string record_uri = 1; + string ref = 2; + google.protobuf.Timestamp seen = 3; +} + +message TakedownRecordResponse { +} + +message UntakedownRecordRequest { + string record_uri = 1; + google.protobuf.Timestamp seen = 2; +} + +message UntakedownRecordResponse { +} + +message CreateActorMuteRequest { + string actor_did = 1; + string subject_did = 2; +} + +message CreateActorMuteResponse {} + +message DeleteActorMuteRequest { + string actor_did = 1; + string subject_did = 2; +} + +message DeleteActorMuteResponse {} + +message ClearActorMutesRequest { + string actor_did = 1; +} + +message ClearActorMutesResponse {} + +message CreateActorMutelistSubscriptionRequest { + string actor_did = 1; + string subject_uri = 2; +} + +message CreateActorMutelistSubscriptionResponse {} + +message DeleteActorMutelistSubscriptionRequest { + string actor_did = 1; + string subject_uri = 2; +} + +message DeleteActorMutelistSubscriptionResponse {} + +message ClearActorMutelistSubscriptionsRequest { + string actor_did = 1; +} + +message ClearActorMutelistSubscriptionsResponse {} diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index 47c2f8f8ca7..386313aa5cb 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -1,108 +1,85 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfile' -import { softDeleted } from '../../../../db/util' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { Actor } from '../../../../db/tables/actor' -import { - ActorService, - ProfileDetailHydrationState, -} from '../../../../services/actor' import { setRepoRev } from '../../../util' import { createPipeline, noRules } from '../../../../pipeline' -import { ModerationService } from '../../../../services/moderation' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' export default function (server: Server, ctx: AppContext) { const getProfile = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.actor.getProfile({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ auth, params, res }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const modService = ctx.services.moderation(ctx.db.getPrimary()) const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) - const [result, repoRev] = await Promise.allSettled([ - getProfile( - { ...params, viewer, canViewTakedowns }, - { db, actorService, modService }, - ), - actorService.getRepoRev(viewer), - ]) + const result = await getProfile( + { ...params, viewer, canViewTakedowns }, + ctx, + ) - if (repoRev.status === 'fulfilled') { - setRepoRev(res, repoRev.value) - } - if (result.status === 'rejected') { - throw result.reason - } + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) + setRepoRev(res, repoRev) return { encoding: 'application/json', - body: result.value, + body: result, } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { actorService } = ctx - const { canViewTakedowns } = params - const actor = await actorService.getActor(params.actor, true) - if (!actor) { +const skeleton = async (input: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = input + const [did] = await ctx.hydrator.actor.getDids([params.actor]) + if (!did) { throw new InvalidRequestError('Profile not found') } - if (!canViewTakedowns && softDeleted(actor)) { - if (actor.takedownRef?.includes('SUSPEND')) { - throw new InvalidRequestError( - 'Account has been temporarily suspended', - 'AccountTakedown', - ) - } else { - throw new InvalidRequestError( - 'Account has been taken down', - 'AccountTakedown', - ) - } - } - return { params, actor } + return { did } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const { params, actor } = state - const { viewer, canViewTakedowns } = params - const hydration = await actorService.views.profileDetailHydration( - [actor.did], - { viewer, includeSoftDeleted: canViewTakedowns }, +const hydration = async (input: { + ctx: Context + params: Params + skeleton: SkeletonState +}) => { + const { ctx, params, skeleton } = input + return ctx.hydrator.hydrateProfilesDetailed( + [skeleton.did], + params.viewer, + true, ) - return { ...state, ...hydration } } -const presentation = (state: HydrationState, ctx: Context) => { - const { actorService } = ctx - const { params, actor } = state - const { viewer } = params - const profiles = actorService.views.profileDetailPresentation( - [actor.did], - state, - { viewer }, - ) - const profile = profiles[actor.did] +const presentation = (input: { + ctx: Context + params: Params + skeleton: SkeletonState + hydration: HydrationState +}) => { + const { ctx, params, skeleton, hydration } = input + const profile = ctx.views.profileDetailed(skeleton.did, hydration) if (!profile) { throw new InvalidRequestError('Profile not found') + } else if ( + !params.canViewTakedowns && + ctx.views.actorIsTakendown(skeleton.did, hydration) + ) { + throw new InvalidRequestError( + 'Account has been suspended', + 'AccountTakedown', + ) } return profile } type Context = { - db: Database - actorService: ActorService - modService: ModerationService + hydrator: Hydrator + views: Views } type Params = QueryParams & { @@ -110,6 +87,4 @@ type Params = QueryParams & { canViewTakedowns: boolean } -type SkeletonState = { params: Params; actor: Actor } - -type HydrationState = SkeletonState & ProfileDetailHydrationState +type SkeletonState = { did: string } diff --git a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts index 21ca13949d2..7a71340d892 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts @@ -2,28 +2,21 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfiles' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { - ActorService, - ProfileDetailHydrationState, -} from '../../../../services/actor' import { setRepoRev } from '../../../util' import { createPipeline, noRules } from '../../../../pipeline' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' export default function (server: Server, ctx: AppContext) { const getProfile = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.actor.getProfiles({ auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, res }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) const viewer = auth.credentials.iss - const [result, repoRev] = await Promise.all([ - getProfile({ ...params, viewer }, { db, actorService }), - actorService.getRepoRev(viewer), - ]) + const result = await getProfile({ ...params, viewer }, ctx) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) setRepoRev(res, repoRev) return { @@ -34,45 +27,44 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { actorService } = ctx - const actors = await actorService.getActors(params.actors) - return { params, dids: actors.map((a) => a.did) } +const skeleton = async (input: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = input + const dids = await ctx.hydrator.actor.getDidsDefined(params.actors) + return { dids } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const { params, dids } = state - const { viewer } = params - const hydration = await actorService.views.profileDetailHydration(dids, { - viewer, - }) - return { ...state, ...hydration } +const hydration = async (input: { + ctx: Context + params: Params + skeleton: SkeletonState +}) => { + const { ctx, params, skeleton } = input + return ctx.hydrator.hydrateProfilesDetailed(skeleton.dids, params.viewer) } -const presentation = (state: HydrationState, ctx: Context) => { - const { actorService } = ctx - const { params, dids } = state - const { viewer } = params - const profiles = actorService.views.profileDetailPresentation(dids, state, { - viewer, - }) - const profileViews = mapDefined(dids, (did) => profiles[did]) - return { profiles: profileViews } +const presentation = (input: { + ctx: Context + params: Params + skeleton: SkeletonState + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = input + const profiles = mapDefined(skeleton.dids, (did) => + ctx.views.profileDetailed(did, hydration), + ) + return { profiles } } type Context = { - db: Database - actorService: ActorService + hydrator: Hydrator + views: Views } type Params = QueryParams & { viewer: string | null } -type SkeletonState = { params: Params; dids: string[] } - -type HydrationState = SkeletonState & ProfileDetailHydrationState +type SkeletonState = { dids: string[] } diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index 3bfeccfa28e..622fcd3891c 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -1,13 +1,12 @@ import { mapDefined } from '@atproto/common' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { Actor } from '../../../../db/tables/actor' -import { notSoftDeletedClause } from '../../../../db/util' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getSuggestions' import { createPipeline } from '../../../../pipeline' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' export default function (server: Server, ctx: AppContext) { const getSuggestions = createPipeline( @@ -19,15 +18,8 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getSuggestions({ auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) const viewer = auth.credentials.iss - - const result = await getSuggestions( - { ...params, viewer }, - { db, actorService, graphService }, - ) + const result = await getSuggestions({ ...params, viewer }, ctx) return { encoding: 'application/json', @@ -37,114 +29,80 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db } = ctx - const { viewer } = params - const alreadyIncluded = parseCursor(params.cursor) // @NOTE handles bad cursor e.g. on appview swap - const { ref } = db.db.dynamic - const suggestions = await db.db - .selectFrom('suggested_follow') - .innerJoin('actor', 'actor.did', 'suggested_follow.did') - .where(notSoftDeletedClause(ref('actor'))) - .where('suggested_follow.did', '!=', viewer ?? '') - .whereNotExists((qb) => - qb - .selectFrom('follow') - .selectAll() - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')), - ) - .if(alreadyIncluded.length > 0, (qb) => - qb.where('suggested_follow.order', 'not in', alreadyIncluded), - ) - .selectAll() - .orderBy('suggested_follow.order', 'asc') - .execute() - - // always include first two - const firstTwo = suggestions.filter( - (row) => row.order === 1 || row.order === 2, - ) - const rest = suggestions.filter((row) => row.order !== 1 && row.order !== 2) - const limited = firstTwo.concat(shuffle(rest)).slice(0, params.limit) - - // if the result set ends up getting larger, consider using a seed included in the cursor for for the randomized shuffle - const cursor = - limited.length > 0 - ? limited - .map((row) => row.order.toString()) - .concat(alreadyIncluded.map((id) => id.toString())) - .join(':') - : undefined - - return { params, suggestions: limited, cursor } -} - -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, suggestions } = state - const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles(suggestions, viewer), - graphService.getBlockAndMuteState( - viewer ? suggestions.map((sug) => [viewer, sug.did]) : [], - ), - ]) - return { ...state, bam, actors } +const skeleton = async (input: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = input + // @NOTE for appview swap moving to rkey-based cursors which are somewhat permissive, should not hard-break pagination + const suggestions = await ctx.dataplane.getFollowSuggestions({ + actorDid: params.viewer ?? undefined, + cursor: params.cursor, + limit: params.limit, + }) + let dids = suggestions.dids + if (params.viewer !== null) { + const follows = await ctx.dataplane.getActorFollowsActors({ + actorDid: params.viewer, + targetDids: dids, + }) + dids = dids.filter((did, i) => !follows.uris[i] && did !== params.viewer) + } + return { dids, cursor: parseString(suggestions.cursor) } } -const noBlocksOrMutes = (state: HydrationState) => { - const { viewer } = state.params - if (!viewer) return state - state.suggestions = state.suggestions.filter( - (item) => - !state.bam.block([viewer, item.did]) && - !state.bam.mute([viewer, item.did]), +const hydration = async (input: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = input + return ctx.hydrator.hydrateProfilesDetailed( + skeleton.dids, + params.viewer, + true, ) - return state } -const presentation = (state: HydrationState) => { - const { suggestions, actors, cursor } = state - const suggestedActors = mapDefined(suggestions, (sug) => actors[sug.did]) - return { actors: suggestedActors, cursor } +const noBlocksOrMutes = (input: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = input + skeleton.dids = skeleton.dids.filter( + (did) => + !ctx.views.viewerBlockExists(did, hydration) && + !ctx.views.viewerMuteExists(did, hydration), + ) + return skeleton } -const parseCursor = (cursor?: string): number[] => { - if (!cursor) { - return [] - } - try { - return cursor - .split(':') - .map((id) => parseInt(id, 10)) - .filter((id) => !isNaN(id)) - } catch { - return [] +const presentation = (input: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = input + const actors = mapDefined(skeleton.dids, (did) => + ctx.views.profile(did, hydration), + ) + return { + actors, + cursor: skeleton.cursor, } } -const shuffle = (arr: T[]): T[] => { - return arr - .map((value) => ({ value, sort: Math.random() })) - .sort((a, b) => a.sort - b.sort) - .map(({ value }) => value) -} - type Context = { - db: Database - actorService: ActorService - graphService: GraphService + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views } -type Params = QueryParams & { viewer: string | null } - -type SkeletonState = { params: Params; suggestions: Actor[]; cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap +type Params = QueryParams & { + viewer: string | null } + +type Skeleton = { dids: string[]; cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/actor/searchActors.ts b/packages/bsky/src/api/app/bsky/actor/searchActors.ts index 82f0327ef89..403be45892a 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActors.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActors.ts @@ -1,56 +1,111 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' -import { cleanQuery } from '../../../../services/util/search' +import { mapDefined } from '@atproto/common' +import AtpAgent from '@atproto/api' +import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/searchActors' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' export default function (server: Server, ctx: AppContext) { + const searchActors = createPipeline( + skeleton, + hydration, + noBlocks, + presentation, + ) server.app.bsky.actor.searchActors({ auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params }) => { - const { cursor, limit } = params - const requester = auth.credentials.iss - const rawQuery = params.q ?? params.term - const query = cleanQuery(rawQuery || '') - const db = ctx.db.getReplica('search') - - let results: string[] - let resCursor: string | undefined - if (ctx.searchAgent) { - // @NOTE cursors wont change on appview swap - const res = - await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ - q: query, - cursor, - limit, - }) - results = res.data.actors.map((a) => a.did) - resCursor = res.data.cursor - } else { - const res = await ctx.services - .actor(ctx.db.getReplica('search')) - .getSearchResults({ query, limit, cursor }) - results = res.results.map((a) => a.did) - resCursor = res.cursor + const viewer = auth.credentials.iss + const results = await searchActors({ ...params, viewer }, ctx) + return { + encoding: 'application/json', + body: results, } + }, + }) +} + +const skeleton = async (inputs: SkeletonFnInput) => { + const { ctx, params } = inputs + const term = params.q ?? params.term ?? '' - const actors = await ctx.services - .actor(db) - .views.profiles(results, requester) + // @TODO + // add hits total - const SKIP = [] - const filtered = results.flatMap((did) => { - const actor = actors[did] - if (!actor) return SKIP - if (actor.viewer?.blocking || actor.viewer?.blockedBy) return SKIP - return actor + if (ctx.searchAgent) { + // @NOTE cursors wont change on appview swap + const { data: res } = + await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ + q: term, + cursor: params.cursor, + limit: params.limit, }) + return { + dids: res.actors.map(({ did }) => did), + cursor: parseString(res.cursor), + } + } - return { - encoding: 'application/json', - body: { - cursor: resCursor, - actors: filtered, - }, - } - }, + const res = await ctx.dataplane.searchActors({ + term, + limit: params.limit, + cursor: params.cursor, }) + return { + dids: res.dids, + cursor: parseString(res.cursor), + } +} + +const hydration = async ( + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateProfiles(skeleton.dids, params.viewer) +} + +const noBlocks = (inputs: RulesFnInput) => { + const { ctx, skeleton, hydration } = inputs + skeleton.dids = skeleton.dids.filter( + (did) => !ctx.views.viewerBlockExists(did, hydration), + ) + return skeleton +} + +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = inputs + const actors = mapDefined(skeleton.dids, (did) => + ctx.views.profile(did, hydration), + ) + return { + actors, + cursor: skeleton.cursor, + } +} + +type Context = { + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views + searchAgent?: AtpAgent +} + +type Params = QueryParams & { viewer: string | null } + +type Skeleton = { + dids: string[] + hitsTotal?: number + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index 6a3167fd2d0..8c7507961a7 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -1,56 +1,107 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' +import AtpAgent from '@atproto/api' +import { mapDefined } from '@atproto/common' +import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/searchActorsTypeahead' import { - cleanQuery, - getUserSearchQuerySimple, -} from '../../../../services/util/search' + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' export default function (server: Server, ctx: AppContext) { + const searchActorsTypeahead = createPipeline( + skeleton, + hydration, + noBlocks, + presentation, + ) server.app.bsky.actor.searchActorsTypeahead({ auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth }) => { - const { limit } = params - const requester = auth.credentials.iss - const rawQuery = params.q ?? params.term - const query = cleanQuery(rawQuery || '') - const db = ctx.db.getReplica('search') - - let results: string[] - if (ctx.searchAgent) { - const res = - await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ - q: query, - typeahead: true, - limit, - }) - results = res.data.actors.map((a) => a.did) - } else { - const res = query - ? await getUserSearchQuerySimple(db, { query, limit }) - .selectAll('actor') - .execute() - : [] - results = res.map((a) => a.did) + const viewer = auth.credentials.iss + const results = await searchActorsTypeahead({ ...params, viewer }, ctx) + return { + encoding: 'application/json', + body: results, } + }, + }) +} + +const skeleton = async (inputs: SkeletonFnInput) => { + const { ctx, params } = inputs + const term = params.q ?? params.term ?? '' - const actors = await ctx.services - .actor(db) - .views.profilesBasic(results, requester) + // @TODO + // add typeahead option + // add hits total - const SKIP = [] - const filtered = results.flatMap((did) => { - const actor = actors[did] - if (!actor) return SKIP - if (actor.viewer?.blocking || actor.viewer?.blockedBy) return SKIP - return actor + if (ctx.searchAgent) { + const { data: res } = + await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ + typeahead: true, + q: term, + limit: params.limit, }) + return { + dids: res.actors.map(({ did }) => did), + cursor: parseString(res.cursor), + } + } - return { - encoding: 'application/json', - body: { - actors: filtered, - }, - } - }, + const res = await ctx.dataplane.searchActors({ + term, + limit: params.limit, }) + return { + dids: res.dids, + cursor: parseString(res.cursor), + } +} + +const hydration = async ( + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateProfilesBasic(skeleton.dids, params.viewer) +} + +const noBlocks = (inputs: RulesFnInput) => { + const { ctx, skeleton, hydration } = inputs + skeleton.dids = skeleton.dids.filter( + (did) => !ctx.views.viewerBlockExists(did, hydration), + ) + return skeleton +} + +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = inputs + const actors = mapDefined(skeleton.dids, (did) => + ctx.views.profileBasic(did, hydration), + ) + return { + actors, + } +} + +type Context = { + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views + searchAgent?: AtpAgent +} + +type Params = QueryParams & { viewer: string | null } + +type Skeleton = { + dids: string[] } diff --git a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts index 266839c7711..b138ae1acad 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts @@ -1,69 +1,91 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorFeeds' import AppContext from '../../../../context' -import { TimeCidKeyset, paginate } from '../../../../db/pagination' +import { createPipeline, noRules } from '../../../../pipeline' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { + const getActorFeeds = createPipeline( + skeleton, + hydration, + noRules, + presentation, + ) server.app.bsky.feed.getActorFeeds({ auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params }) => { - const { actor, limit, cursor } = params const viewer = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { feeds: [] }, - } + const result = await getActorFeeds({ ...params, viewer }, ctx) + return { + encoding: 'application/json', + body: result, } + }, + }) +} - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const feedService = ctx.services.feed(db) - - const creatorRes = await actorService.getActor(actor) - if (!creatorRes) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } +const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { feedUris: [] } + } + const [did] = await ctx.hydrator.actor.getDids([params.actor]) + if (!did) { + throw new InvalidRequestError('Profile not found') + } + const feedsRes = await ctx.dataplane.getActorFeeds({ + actorDid: did, + cursor: params.cursor, + limit: params.limit, + }) + return { + feedUris: feedsRes.uris, + cursor: parseString(feedsRes.cursor), + } +} - const { ref } = db.db.dynamic - let feedsQb = feedService - .selectFeedGeneratorQb(viewer) - .where('feed_generator.creator', '=', creatorRes.did) +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateFeedGens(skeleton.feedUris, params.viewer) +} - const keyset = new TimeCidKeyset( - ref('feed_generator.createdAt'), - ref('feed_generator.cid'), - ) - feedsQb = paginate(feedsQb, { - limit, - cursor, - keyset, - }) +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feeds = mapDefined(skeleton.feedUris, (uri) => + ctx.views.feedGenerator(uri, hydration), + ) + return { + feeds, + cursor: skeleton.cursor, + } +} - const [feedsRes, profiles] = await Promise.all([ - feedsQb.execute(), - actorService.views.profiles([creatorRes], viewer), - ]) - if (!profiles[creatorRes.did]) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } +type Context = { + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient +} - const feeds = mapDefined(feedsRes, (row) => { - const feed = { - ...row, - viewer: viewer ? { like: row.viewerLike } : undefined, - } - return feedService.views.formatFeedGeneratorView(feed, profiles) - }) +type Params = QueryParams & { viewer: string | null } - return { - encoding: 'application/json', - body: { - cursor: keyset.packFromResult(feedsRes), - feeds, - }, - } - }, - }) +type Skeleton = { + feedUris: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 48d6437e494..4f026418bc4 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -1,19 +1,16 @@ import { InvalidRequestError } from '@atproto/xrpc-server' +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorLikes' -import { FeedKeyset } from '../util/feed' -import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { setRepoRev } from '../../../util' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { Database } from '../../../../db' -import { ActorService } from '../../../../services/actor' -import { GraphService } from '../../../../services/graph' +import { clearlyBadCursor, setRepoRev } from '../../../util' import { createPipeline } from '../../../../pipeline' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { creatorFromUri } from '../../../../views/util' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getActorLikes = createPipeline( @@ -26,19 +23,10 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, res }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) - const [result, repoRev] = await Promise.all([ - getActorLikes( - { ...params, viewer }, - { db, actorService, feedService, graphService }, - ), - actorService.getRepoRev(viewer), - ]) + const result = await getActorLikes({ ...params, viewer }, ctx) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) setRepoRev(res, repoRev) return { @@ -49,81 +37,80 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db, actorService, feedService } = ctx +const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs const { actor, limit, cursor, viewer } = params - const { ref } = db.db.dynamic - - const actorRes = await actorService.getActor(actor) - if (!actorRes) { - throw new InvalidRequestError('Profile not found') + if (clearlyBadCursor(cursor)) { + return { items: [] } } - const actorDid = actorRes.did - - if (!viewer || viewer !== actorDid) { + const [actorDid] = await ctx.hydrator.actor.getDids([actor]) + if (!actorDid || !viewer || viewer !== actorDid) { throw new InvalidRequestError('Profile not found') } - if (FeedKeyset.clearlyBad(cursor)) { - return { params, feedItems: [] } - } - - let feedItemsQb = feedService - .selectFeedItemQb() - .innerJoin('like', 'like.subject', 'feed_item.uri') - .where('like.creator', '=', actorDid) - - const keyset = new FeedKeyset(ref('like.sortAt'), ref('like.cid')) - - feedItemsQb = paginate(feedItemsQb, { + const likesRes = await ctx.dataplane.getActorLikes({ + actorDid, limit, cursor, - keyset, }) - const feedItems = await feedItemsQb.execute() + const items = likesRes.likes.map((l) => ({ post: { uri: l.subject } })) - return { params, feedItems, cursor: keyset.packFromResult(feedItems) } + return { + items, + cursor: parseString(likesRes.cursor), + } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer) } -const noPostBlocks = (state: HydrationState) => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter( - (item) => !viewer || !state.bam.block([viewer, item.postAuthorDid]), - ) - return state +const noPostBlocks = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + skeleton.items = skeleton.items.filter((item) => { + const creator = creatorFromUri(item.post.uri) + return !ctx.views.viewerBlockExists(creator, hydration) + }) + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) - return { feed, cursor } +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), + ) + return { + feed, + cursor: skeleton.cursor, + } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient } type Params = QueryParams & { viewer: string | null } -type SkeletonState = { params: Params; feedItems: FeedRow[]; cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState +type Skeleton = { + items: FeedItem[] + cursor?: string +} diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index 6b344547a45..02e2240828f 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -1,19 +1,21 @@ +import { mapDefined } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' -import { FeedKeyset } from '../util/feed' -import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { setRepoRev } from '../../../util' -import { Database } from '../../../../db' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { ActorService } from '../../../../services/actor' -import { GraphService } from '../../../../services/graph' +import { clearlyBadCursor, setRepoRev } from '../../../util' import { createPipeline } from '../../../../pipeline' +import { + HydrationState, + Hydrator, + mergeStates, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { Actor } from '../../../../hydration/actor' +import { FeedItem } from '../../../../hydration/feed' +import { FeedType } from '../../../../proto/bsky_pb' export default function (server: Server, ctx: AppContext) { const getAuthorFeed = createPipeline( @@ -25,24 +27,14 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getAuthorFeed({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, res }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) - const { viewer } = ctx.authVerifier.parseCreds(auth) + const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) - const [result, repoRev] = await Promise.all([ - getAuthorFeed( - { - ...params, - includeSoftDeleted: auth.credentials.type === 'role', - viewer, - }, - { db, actorService, feedService, graphService }, - ), - actorService.getRepoRev(viewer), - ]) + const result = await getAuthorFeed( + { ...params, viewer, includeTakedowns: canViewTakedowns }, + ctx, + ) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) setRepoRev(res, repoRev) return { @@ -53,135 +45,122 @@ export default function (server: Server, ctx: AppContext) { }) } -export const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { cursor, limit, actor, filter, viewer, includeSoftDeleted } = params - const { db, actorService, feedService, graphService } = ctx - const { ref } = db.db.dynamic +const FILTER_TO_FEED_TYPE = { + posts_with_replies: undefined, // default: all posts, replies, and reposts + posts_no_replies: FeedType.POSTS_NO_REPLIES, + posts_with_media: FeedType.POSTS_WITH_MEDIA, + posts_and_author_threads: FeedType.POSTS_AND_AUTHOR_THREADS, +} - // maybe resolve did first - const actorRes = await actorService.getActor(actor, includeSoftDeleted) - if (!actorRes) { +export const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + const [did] = await ctx.hydrator.actor.getDids([params.actor]) + if (!did) { throw new InvalidRequestError('Profile not found') } - const actorDid = actorRes.did - - // verify there is not a block between requester & subject - if (viewer !== null) { - const blocks = await graphService.getBlockState([[viewer, actorDid]]) - if (blocks.blocking([viewer, actorDid])) { - throw new InvalidRequestError( - `Requester has blocked actor: ${actor}`, - 'BlockedActor', - ) - } - if (blocks.blockedBy([viewer, actorDid])) { - throw new InvalidRequestError( - `Requester is blocked by actor: $${actor}`, - 'BlockedByActor', - ) - } + const actors = await ctx.hydrator.actor.getActors( + [did], + params.includeTakedowns, + ) + const actor = actors.get(did) + if (!actor) { + throw new InvalidRequestError('Profile not found') } - - if (FeedKeyset.clearlyBad(cursor)) { - return { params, feedItems: [] } + if (clearlyBadCursor(params.cursor)) { + return { actor, items: [] } } - - // defaults to posts, reposts, and replies - let feedItemsQb = feedService - .selectFeedItemQb() - .where('originatorDid', '=', actorDid) - - if (filter === 'posts_with_media') { - feedItemsQb = feedItemsQb - // only your own posts - .where('type', '=', 'post') - // only posts with media - .whereExists((qb) => - qb - .selectFrom('post_embed_image') - .select('post_embed_image.postUri') - .whereRef('post_embed_image.postUri', '=', 'feed_item.postUri'), - ) - } else if (filter === 'posts_no_replies') { - feedItemsQb = feedItemsQb.where((qb) => - qb.where('post.replyParent', 'is', null).orWhere('type', '=', 'repost'), - ) - } else if (filter === 'posts_and_author_threads') { - feedItemsQb = feedItemsQb.where((qb) => - qb - .where('type', '=', 'repost') - .orWhere('post.replyParent', 'is', null) - .orWhere('post.replyRoot', 'like', `at://${actorDid}/%`), - ) - } - - const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) - - feedItemsQb = paginate(feedItemsQb, { - limit, - cursor, - keyset, + const res = await ctx.dataplane.getAuthorFeed({ + actorDid: did, + limit: params.limit, + cursor: params.cursor, + feedType: FILTER_TO_FEED_TYPE[params.filter], }) - - const feedItems = await feedItemsQb.execute() - return { - params, - feedItems, - cursor: keyset.packFromResult(feedItems), + actor, + items: res.items.map((item) => ({ + post: { uri: item.uri, cid: item.cid || undefined }, + repost: item.repost + ? { uri: item.repost, cid: item.repostCid || undefined } + : undefined, + })), + cursor: parseString(res.cursor), } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - includeSoftDeleted: params.includeSoftDeleted, - }) - return { ...state, ...hydrated } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}): Promise => { + const { ctx, params, skeleton } = inputs + const [feedPostState, profileViewerState = {}] = await Promise.all([ + ctx.hydrator.hydrateFeedItems( + skeleton.items, + params.viewer, + params.includeTakedowns, + ), + params.viewer + ? ctx.hydrator.hydrateProfileViewers([skeleton.actor.did], params.viewer) + : undefined, + ]) + return mergeStates(feedPostState, profileViewerState) } -const noBlocksOrMutedReposts = (state: HydrationState) => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter((item) => { - if (!viewer) return true +const noBlocksOrMutedReposts = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}): Skeleton => { + const { ctx, skeleton, hydration } = inputs + const relationship = hydration.profileViewers?.get(skeleton.actor.did) + if (relationship?.blocking || relationship?.blockingByList) { + throw new InvalidRequestError( + `Requester has blocked actor: ${skeleton.actor.did}`, + 'BlockedActor', + ) + } + if (relationship?.blockedBy || relationship?.blockedByList) { + throw new InvalidRequestError( + `Requester is blocked by actor: ${skeleton.actor.did}`, + 'BlockedByActor', + ) + } + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( - !state.bam.block([viewer, item.postAuthorDid]) && - (item.type === 'post' || !state.bam.mute([viewer, item.postAuthorDid])) + !bam.authorBlocked && + !bam.originatorBlocked && + !(bam.authorMuted && !bam.originatorMuted) ) }) - return state + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) - return { feed, cursor } +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), + ) + return { feed, cursor: skeleton.cursor } } type Context = { - db: Database - actorService: ActorService - feedService: FeedService - graphService: GraphService + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient } -type Params = QueryParams & { - viewer: string | null - includeSoftDeleted: boolean -} +type Params = QueryParams & { viewer: string | null; includeTakedowns: boolean } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + actor: Actor + items: FeedItem[] cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 66461cd3bbb..aae633e4f2a 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -1,3 +1,4 @@ +import { mapDefined } from '@atproto/common' import { InvalidRequestError, UpstreamFailureError, @@ -5,25 +6,27 @@ import { serverTimingHeader, } from '@atproto/xrpc-server' import { ResponseType, XRPCError } from '@atproto/xrpc' -import { - DidDocument, - PoorlyFormattedDidDocumentError, - getFeedGen, -} from '@atproto/identity' import { AtpAgent, AppBskyFeedGetFeedSkeleton } from '@atproto/api' import { noUndefinedVals } from '@atproto/common' import { QueryParams as GetFeedParams } from '../../../../lexicon/types/app/bsky/feed/getFeed' import { OutputSchema as SkeletonOutput } from '../../../../lexicon/types/app/bsky/feed/getFeedSkeleton' -import { SkeletonFeedPost } from '../../../../lexicon/types/app/bsky/feed/defs' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { Database } from '../../../../db' import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { createPipeline } from '../../../../pipeline' + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { FeedItem } from '../../../../hydration/feed' +import { GetIdentityByDidResponse } from '../../../../proto/bsky_pb' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../../../../data-plane' export default function (server: Server, ctx: AppContext) { const getFeed = createPipeline( @@ -35,8 +38,6 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeed({ auth: ctx.authVerifier.standardOptionalAnyAud, handler: async ({ params, auth, req }) => { - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) const viewer = auth.credentials.iss const headers = noUndefinedVals({ authorization: req.headers['authorization'], @@ -44,13 +45,8 @@ export default function (server: Server, ctx: AppContext) { }) // @NOTE feed cursors should not be affected by appview swap const { timerSkele, timerHydr, resHeaders, ...result } = await getFeed( - { ...params, viewer }, - { - db, - feedService, - appCtx: ctx, - headers, - }, + { ...params, viewer, headers }, + ctx, ) return { @@ -66,125 +62,113 @@ export default function (server: Server, ctx: AppContext) { } const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { + inputs: SkeletonFnInput, +): Promise => { + const { ctx, params } = inputs const timerSkele = new ServerTimer('skele').start() - const feedParams: GetFeedParams = { - feed: params.feed, - limit: params.limit, - cursor: params.cursor, - } - const { feedItems, cursor, resHeaders, ...passthrough } = - await skeletonFromFeedGen(ctx, feedParams) + const { + feedItems: algoItems, + cursor, + resHeaders, + ...passthrough + } = await skeletonFromFeedGen(ctx, params) + return { - params, cursor, - feedItems, + items: algoItems.map(toFeedItem), timerSkele: timerSkele.stop(), + timerHydr: new ServerTimer('hydr').start(), resHeaders, passthrough, } } -const hydration = async (state: SkeletonState, ctx: Context) => { +const hydration = async ( + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs const timerHydr = new ServerTimer('hydr').start() - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated, timerHydr: timerHydr.stop() } + const hydration = await ctx.hydrator.hydrateFeedItems( + skeleton.items, + params.viewer, + ) + skeleton.timerHydr = timerHydr.stop() + return hydration } -const noBlocksOrMutes = (state: HydrationState) => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter((item) => { - if (!viewer) return true +const noBlocksOrMutes = (inputs: RulesFnInput) => { + const { ctx, skeleton, hydration } = inputs + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( - !state.bam.block([viewer, item.postAuthorDid]) && - !state.bam.block([viewer, item.originatorDid]) && - !state.bam.mute([viewer, item.postAuthorDid]) && - !state.bam.mute([viewer, item.originatorDid]) + !bam.authorBlocked && + !bam.authorMuted && + !bam.originatorBlocked && + !bam.originatorMuted ) }) - return state + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, passthrough, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, params, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => { + return ctx.views.feedViewPost(item, hydration) + }).slice(0, params.limit) return { feed, - cursor, - timerSkele: state.timerSkele, - timerHydr: state.timerHydr, - resHeaders: state.resHeaders, - ...passthrough, + cursor: skeleton.cursor, + timerSkele: skeleton.timerSkele, + timerHydr: skeleton.timerHydr, + resHeaders: skeleton.resHeaders, + ...skeleton.passthrough, } } -type Context = { - db: Database - feedService: FeedService - appCtx: AppContext +type Context = AppContext + +type Params = GetFeedParams & { + viewer: string | null headers: Record } -type Params = GetFeedParams & { viewer: string | null } - -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + items: FeedItem[] passthrough: Record // pass through additional items in feedgen response resHeaders?: Record cursor?: string timerSkele: ServerTimer -} - -type HydrationState = SkeletonState & - FeedHydrationState & { feedItems: FeedRow[]; timerHydr: ServerTimer } - -type AlgoResponse = { - feedItems: FeedRow[] - resHeaders?: Record - cursor?: string + timerHydr: ServerTimer } const skeletonFromFeedGen = async ( ctx: Context, - params: GetFeedParams, + params: Params, ): Promise => { - const { db, appCtx, headers } = ctx - const { feed } = params - // Resolve and fetch feed skeleton - const found = await db.db - .selectFrom('feed_generator') - .where('uri', '=', feed) - .select('feedDid') - .executeTakeFirst() - if (!found) { + const { feed, headers } = params + const found = await ctx.hydrator.feed.getFeedGens([feed], true) + const feedDid = await found.get(feed)?.record.did + if (!feedDid) { throw new InvalidRequestError('could not find feed') } - const feedDid = found.feedDid - let resolved: DidDocument | null + let identity: GetIdentityByDidResponse try { - resolved = await appCtx.idResolver.did.resolve(feedDid) + identity = await ctx.dataplane.getIdentityByDid({ did: feedDid }) } catch (err) { - if (err instanceof PoorlyFormattedDidDocumentError) { - throw new InvalidRequestError(`invalid did document: ${feedDid}`) + if (isDataplaneError(err, Code.NotFound)) { + throw new InvalidRequestError(`could not resolve identity: ${feedDid}`) } throw err } - if (!resolved) { - throw new InvalidRequestError(`could not resolve did document: ${feedDid}`) - } - const fgEndpoint = getFeedGen(resolved) + const services = unpackIdentityServices(identity.services) + const fgEndpoint = getServiceEndpoint(services, { + id: 'bsky_fg', + type: 'BskyFeedGenerator', + }) if (!fgEndpoint) { throw new InvalidRequestError( `invalid feed generator service details in did document: ${feedDid}`, @@ -197,9 +181,16 @@ const skeletonFromFeedGen = async ( let resHeaders: Record | undefined = undefined try { // @TODO currently passthrough auth headers from pds - const result = await agent.api.app.bsky.feed.getFeedSkeleton(params, { - headers, - }) + const result = await agent.api.app.bsky.feed.getFeedSkeleton( + { + feed: params.feed, + limit: params.limit, + cursor: params.cursor, + }, + { + headers, + }, + ) skeleton = result.data if (result.headers['content-language']) { resHeaders = { @@ -225,33 +216,30 @@ const skeletonFromFeedGen = async ( } const { feed: feedSkele, ...skele } = skeleton - const feedItems = await skeletonToFeedItems( - feedSkele.slice(0, params.limit), - ctx, - ) + const feedItems = feedSkele.map((item) => ({ + itemUri: + typeof item.reason?.repost === 'string' ? item.reason.repost : item.post, + postUri: item.post, + })) return { ...skele, resHeaders, feedItems } } -const skeletonToFeedItems = async ( - skeleton: SkeletonFeedPost[], - ctx: Context, -): Promise => { - const { feedService } = ctx - const feedItemUris = skeleton.map(getSkeleFeedItemUri) - const feedItemsRaw = await feedService.getFeedItems(feedItemUris) - const results: FeedRow[] = [] - for (const skeleItem of skeleton) { - const feedItem = feedItemsRaw[getSkeleFeedItemUri(skeleItem)] - if (feedItem && feedItem.postUri === skeleItem.post) { - results.push(feedItem) - } - } - return results +export type AlgoResponse = { + feedItems: AlgoResponseItem[] + resHeaders?: Record + cursor?: string } -const getSkeleFeedItemUri = (item: SkeletonFeedPost) => { - return typeof item.reason?.repost === 'string' - ? item.reason.repost - : item.post +export type AlgoResponseItem = { + itemUri: string + postUri: string } + +export const toFeedItem = (feedItem: AlgoResponseItem): FeedItem => ({ + post: { uri: feedItem.postUri }, + repost: + feedItem.itemUri === feedItem.postUri + ? undefined + : { uri: feedItem.itemUri }, +}) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts index 125af1db9b9..57f86af9a28 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts @@ -1,11 +1,13 @@ import { InvalidRequestError } from '@atproto/xrpc-server' -import { - DidDocument, - PoorlyFormattedDidDocumentError, - getFeedGen, -} from '@atproto/identity' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { GetIdentityByDidResponse } from '../../../../proto/bsky_pb' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../../../../data-plane' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerator({ @@ -14,47 +16,37 @@ export default function (server: Server, ctx: AppContext) { const { feed } = params const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - - const got = await feedService.getFeedGeneratorInfos([feed], viewer) - const feedInfo = got[feed] + const hydration = await ctx.hydrator.hydrateFeedGens([feed], viewer) + const feedInfo = hydration.feedgens?.get(feed) if (!feedInfo) { throw new InvalidRequestError('could not find feed') } - const feedDid = feedInfo.feedDid - let resolved: DidDocument | null + const feedDid = feedInfo.record.did + let identity: GetIdentityByDidResponse try { - resolved = await ctx.idResolver.did.resolve(feedDid) + identity = await ctx.dataplane.getIdentityByDid({ did: feedDid }) } catch (err) { - if (err instanceof PoorlyFormattedDidDocumentError) { - throw new InvalidRequestError(`invalid did document: ${feedDid}`) + if (isDataplaneError(err, Code.NotFound)) { + throw new InvalidRequestError( + `could not resolve identity: ${feedDid}`, + ) } throw err } - if (!resolved) { - throw new InvalidRequestError( - `could not resolve did document: ${feedDid}`, - ) - } - const fgEndpoint = getFeedGen(resolved) + const services = unpackIdentityServices(identity.services) + const fgEndpoint = getServiceEndpoint(services, { + id: 'bsky_fg', + type: 'BskyFeedGenerator', + }) if (!fgEndpoint) { throw new InvalidRequestError( `invalid feed generator service details in did document: ${feedDid}`, ) } - const profiles = await actorService.views.profilesBasic( - [feedInfo.creator], - viewer, - ) - const feedView = feedService.views.formatFeedGeneratorView( - feedInfo, - profiles, - ) + const feedView = ctx.views.feedGenerator(feed, hydration) if (!feedView) { throw new InvalidRequestError('could not find feed') } diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts index ed6df5760cb..34547948204 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -1,10 +1,10 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getFeedGenerators' import AppContext from '../../../../context' -import { FeedGenInfo, FeedService } from '../../../../services/feed' import { createPipeline, noRules } from '../../../../pipeline' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { Database } from '../../../../db' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' export default function (server: Server, ctx: AppContext) { const getFeedGenerators = createPipeline( @@ -16,17 +16,8 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerators({ auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth }) => { - const { feeds } = params const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - - const view = await getFeedGenerators( - { feeds, viewer }, - { db, feedService, actorService }, - ) - + const view = await getFeedGenerators({ ...params, viewer }, ctx) return { encoding: 'application/json', body: view, @@ -35,46 +26,42 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (params: Params, ctx: Context) => { - const { feedService } = ctx - const genInfos = await feedService.getFeedGeneratorInfos( - params.feeds, - params.viewer, - ) +const skeleton = async (inputs: { params: Params }): Promise => { return { - params, - generators: Object.values(genInfos), + feedUris: inputs.params.feeds, } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const profiles = await actorService.views.profilesBasic( - state.generators.map((gen) => gen.creator), - state.params.viewer, - ) - return { - ...state, - profiles, - } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateFeedGens(skeleton.feedUris, params.viewer) } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const feeds = mapDefined(state.generators, (gen) => - feedService.views.formatFeedGeneratorView(gen, state.profiles), +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feeds = mapDefined(skeleton.feedUris, (uri) => + ctx.views.feedGenerator(uri, hydration), ) - return { feeds } + return { + feeds, + } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService + hydrator: Hydrator + views: Views } -type Params = { viewer: string | null; feeds: string[] } +type Params = QueryParams & { viewer: string | null } -type SkeletonState = { params: Params; generators: FeedGenInfo[] } - -type HydrationState = SkeletonState & { profiles: ActorInfoMap } +type Skeleton = { + feedUris: string[] +} diff --git a/packages/bsky/src/api/app/bsky/feed/getLikes.ts b/packages/bsky/src/api/app/bsky/feed/getLikes.ts index 2d59656a517..582dd78a073 100644 --- a/packages/bsky/src/api/app/bsky/feed/getLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getLikes.ts @@ -1,29 +1,22 @@ import { mapDefined } from '@atproto/common' +import { normalizeDatetimeAlways } from '@atproto/syntax' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getLikes' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' -import { notSoftDeletedClause } from '../../../../db/util' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { Actor } from '../../../../db/tables/actor' -import { Database } from '../../../../db' import { createPipeline } from '../../../../pipeline' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { parseString } from '../../../../hydration/util' +import { creatorFromUri } from '../../../../views/util' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { const getLikes = createPipeline(skeleton, hydration, noBlocks, presentation) server.app.bsky.feed.getLikes({ auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) const viewer = auth.credentials.iss - - const result = await getLikes( - { ...params, viewer }, - { db, actorService, graphService }, - ) + const result = await getLikes({ ...params, viewer }, ctx) return { encoding: 'application/json', @@ -33,99 +26,86 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db } = ctx - const { uri, cid, limit, cursor } = params - const { ref } = db.db.dynamic - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, likes: [] } - } - - let builder = db.db - .selectFrom('like') - .where('like.subject', '=', uri) - .innerJoin('actor as creator', 'creator.did', 'like.creator') - .where(notSoftDeletedClause(ref('creator'))) - .selectAll('creator') - .select([ - 'like.cid as cid', - 'like.createdAt as createdAt', - 'like.indexedAt as indexedAt', - 'like.sortAt as sortAt', - ]) - - if (cid) { - builder = builder.where('like.subjectCid', '=', cid) +const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { likes: [] } } - - const keyset = new TimeCidKeyset(ref('like.sortAt'), ref('like.cid')) - builder = paginate(builder, { - limit, - cursor, - keyset, + const likesRes = await ctx.hydrator.dataplane.getLikesBySubject({ + subject: { uri: params.uri, cid: params.cid }, + cursor: params.cursor, + limit: params.limit, }) - - const likes = await builder.execute() - - return { params, likes, cursor: keyset.packFromResult(likes) } + return { + likes: likesRes.uris, + cursor: parseString(likesRes.cursor), + } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, likes } = state - const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles(likes, viewer), - graphService.getBlockAndMuteState( - viewer ? likes.map((like) => [viewer, like.did]) : [], - ), - ]) - return { ...state, bam, actors } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateLikes(skeleton.likes, params.viewer) } -const noBlocks = (state: HydrationState) => { - const { viewer } = state.params - if (!viewer) return state - state.likes = state.likes.filter( - (item) => !state.bam.block([viewer, item.did]), - ) - return state +const noBlocks = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + skeleton.likes = skeleton.likes.filter((uri) => { + const creator = creatorFromUri(uri) + return !ctx.views.viewerBlockExists(creator, hydration) + }) + return skeleton } -const presentation = (state: HydrationState) => { - const { params, likes, actors, cursor } = state - const { uri, cid } = params - const likesView = mapDefined(likes, (like) => - actors[like.did] - ? { - createdAt: like.createdAt, - indexedAt: like.indexedAt, - actor: actors[like.did], - } - : undefined, - ) - return { likes: likesView, cursor, uri, cid } +const presentation = (inputs: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, params, skeleton, hydration } = inputs + const likeViews = mapDefined(skeleton.likes, (uri) => { + const like = hydration.likes?.get(uri) + if (!like || !like.record) { + return + } + const creatorDid = creatorFromUri(uri) + const actor = ctx.views.profile(creatorDid, hydration) + if (!actor) { + return + } + return { + actor, + createdAt: normalizeDatetimeAlways(like.record.createdAt), + indexedAt: like.sortedAt.toISOString(), + } + }) + return { + likes: likeViews, + cursor: skeleton.cursor, + uri: params.uri, + cid: params.cid, + } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { viewer: string | null } -type SkeletonState = { - params: Params - likes: (Actor & { createdAt: string })[] +type Skeleton = { + likes: string[] cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap -} diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index 478d9b08efa..ec182e39ac5 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -1,18 +1,14 @@ import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getListFeed' -import { FeedKeyset, getFeedDateThreshold } from '../util/feed' -import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { setRepoRev } from '../../../util' -import { Database } from '../../../../db' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { ActorService } from '../../../../services/actor' -import { GraphService } from '../../../../services/graph' +import { clearlyBadCursor, setRepoRev } from '../../../util' import { createPipeline } from '../../../../pipeline' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { mapDefined } from '@atproto/common' +import { parseString } from '../../../../hydration/util' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getListFeed = createPipeline( @@ -25,19 +21,10 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, res }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) - const [result, repoRev] = await Promise.all([ - getListFeed( - { ...params, viewer }, - { db, actorService, feedService, graphService }, - ), - actorService.getRepoRev(viewer), - ]) + const result = await getListFeed({ ...params, viewer }, ctx) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) setRepoRev(res, repoRev) return { @@ -48,84 +35,78 @@ export default function (server: Server, ctx: AppContext) { }) } -export const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { list, cursor, limit } = params - const { db } = ctx - const { ref } = db.db.dynamic - - if (FeedKeyset.clearlyBad(cursor)) { - return { params, feedItems: [] } +export const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { items: [] } } - - const keyset = new FeedKeyset(ref('post.sortAt'), ref('post.cid')) - const sortFrom = keyset.unpack(cursor)?.primary - - let builder = ctx.feedService - .selectPostQb() - .innerJoin('list_item', 'list_item.subjectDid', 'post.creator') - .where('list_item.listUri', '=', list) - .where('post.sortAt', '>', getFeedDateThreshold(sortFrom, 3)) - - builder = paginate(builder, { - limit, - cursor, - keyset, - tryIndex: true, + const res = await ctx.dataplane.getListFeed({ + listUri: params.list, + limit: params.limit, + cursor: params.cursor, }) - const feedItems = await builder.execute() - return { - params, - feedItems, - cursor: keyset.packFromResult(feedItems), + items: res.items.map((item) => ({ + post: { uri: item.uri, cid: item.cid || undefined }, + repost: item.repost + ? { uri: item.repost, cid: item.repostCid || undefined } + : undefined, + })), + cursor: parseString(res.cursor), } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}): Promise => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer) } -const noBlocksOrMutes = (state: HydrationState) => { - const { viewer } = state.params - if (!viewer) return state - state.feedItems = state.feedItems.filter( - (item) => - !state.bam.block([viewer, item.postAuthorDid]) && - !state.bam.mute([viewer, item.postAuthorDid]), - ) - return state +const noBlocksOrMutes = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}): Skeleton => { + const { ctx, skeleton, hydration } = inputs + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) + return ( + !bam.authorBlocked && + !bam.authorMuted && + !bam.originatorBlocked && + !bam.originatorMuted + ) + }) + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) - return { feed, cursor } +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), + ) + return { feed, cursor: skeleton.cursor } } type Context = { - db: Database - actorService: ActorService - feedService: FeedService - graphService: GraphService + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient } type Params = QueryParams & { viewer: string | null } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + items: FeedItem[] cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 18d9d3124d0..cfe59e290fb 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -1,27 +1,22 @@ import { InvalidRequestError } from '@atproto/xrpc-server' -import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' +import { isNotFoundPost } from '../../../../lexicon/types/app/bsky/feed/defs' import { - BlockedPost, - NotFoundPost, - ThreadViewPost, - isNotFoundPost, -} from '../../../../lexicon/types/app/bsky/feed/defs' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPostThread' + QueryParams, + OutputSchema, +} from '../../../../lexicon/types/app/bsky/feed/getPostThread' import AppContext from '../../../../context' -import { - FeedService, - FeedRow, - FeedHydrationState, -} from '../../../../services/feed' -import { - getAncestorsAndSelfQb, - getDescendentsQb, -} from '../../../../services/util/post' -import { Database } from '../../../../db' import { setRepoRev } from '../../../util' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { createPipeline, noRules } from '../../../../pipeline' +import { + HydrationFnInput, + PresentationFnInput, + SkeletonFnInput, + createPipeline, + noRules, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient, isDataplaneError, Code } from '../../../../data-plane' export default function (server: Server, ctx: AppContext) { const getPostThread = createPipeline( @@ -34,299 +29,85 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, res }) => { const { viewer } = ctx.authVerifier.parseCreds(auth) - const db = ctx.db.getReplica('thread') - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - const [result, repoRev] = await Promise.allSettled([ - getPostThread({ ...params, viewer }, { db, feedService, actorService }), - actorService.getRepoRev(viewer), - ]) - - if (repoRev.status === 'fulfilled') { - setRepoRev(res, repoRev.value) - } - if (result.status === 'rejected') { - throw result.reason + let result: OutputSchema + try { + result = await getPostThread({ ...params, viewer }, ctx) + } catch (err) { + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) + setRepoRev(res, repoRev) + throw err } + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) + setRepoRev(res, repoRev) + return { encoding: 'application/json', - body: result.value, + body: result, } }, }) } -const skeleton = async (params: Params, ctx: Context) => { - const threadData = await getThreadData(params, ctx) - if (!threadData) { - throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') - } - return { params, threadData } -} - -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { - threadData, - params: { viewer }, - } = state - const relevant = getRelevantIds(threadData) - const hydrated = await feedService.feedHydration({ ...relevant, viewer }) - return { ...state, ...hydrated } -} - -const presentation = (state: HydrationState, ctx: Context) => { - const { params, profiles } = state - const { actorService } = ctx - const actors = actorService.views.profileBasicPresentation( - Object.keys(profiles), - state, - params.viewer, - ) - const thread = composeThread( - state.threadData, - actors, - state, - ctx, - params.viewer, - ) - if (isNotFoundPost(thread)) { - // @TODO technically this could be returned as a NotFoundPost based on lexicon - throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') - } - return { thread } -} - -const composeThread = ( - threadData: PostThread, - actors: ActorInfoMap, - state: HydrationState, - ctx: Context, - viewer: string | null, -) => { - const { feedService } = ctx - const { posts, threadgates, embeds, blocks, labels, lists } = state - - const post = feedService.views.formatPostView( - threadData.post.postUri, - actors, - posts, - threadgates, - embeds, - labels, - lists, - viewer, - ) - - // replies that are invalid due to reply-gating: - // a. may appear as the anchor post, but without any parent or replies. - // b. may not appear anywhere else in the thread. - const isAnchorPost = state.threadData.post.uri === threadData.post.postUri - const info = posts[threadData.post.postUri] - // @TODO re-enable invalidReplyRoot check - // const badReply = !!info?.invalidReplyRoot || !!info?.violatesThreadGate - const badReply = !!info?.violatesThreadGate - const violatesBlock = (post && blocks[post.uri]?.reply) ?? false - const omitBadReply = !isAnchorPost && (badReply || violatesBlock) - - if (!post || omitBadReply) { - return { - $type: 'app.bsky.feed.defs#notFoundPost', - uri: threadData.post.postUri, - notFound: true, - } - } - - if (post.author.viewer?.blocking || post.author.viewer?.blockedBy) { +const skeleton = async (inputs: SkeletonFnInput) => { + const { ctx, params } = inputs + try { + const res = await ctx.dataplane.getThread({ + postUri: params.uri, + above: params.parentHeight, + below: params.depth, + }) return { - $type: 'app.bsky.feed.defs#blockedPost', - uri: threadData.post.postUri, - blocked: true, - author: { - did: post.author.did, - viewer: post.author.viewer - ? { - blockedBy: post.author.viewer?.blockedBy, - blocking: post.author.viewer?.blocking, - } - : undefined, - }, + anchor: params.uri, + uris: res.uris, } - } - - let parent - if (threadData.parent && !badReply && !violatesBlock) { - if (threadData.parent instanceof ParentNotFoundError) { - parent = { - $type: 'app.bsky.feed.defs#notFoundPost', - uri: threadData.parent.uri, - notFound: true, + } catch (err) { + if (isDataplaneError(err, Code.NotFound)) { + return { + anchor: params.uri, + uris: [], } } else { - parent = composeThread(threadData.parent, actors, state, ctx, viewer) + throw err } } - - let replies: (ThreadViewPost | NotFoundPost | BlockedPost)[] | undefined - if (threadData.replies && !badReply) { - replies = threadData.replies.flatMap((reply) => { - const thread = composeThread(reply, actors, state, ctx, viewer) - // e.g. don't bother including #postNotFound reply placeholders for takedowns. either way matches api contract. - const skip = [] - return isNotFoundPost(thread) ? skip : thread - }) - } - - return { - $type: 'app.bsky.feed.defs#threadViewPost', - post, - parent, - replies, - } } -const getRelevantIds = ( - thread: PostThread, -): { dids: Set; uris: Set } => { - const dids = new Set() - const uris = new Set() - if (thread.parent && !(thread.parent instanceof ParentNotFoundError)) { - const fromParent = getRelevantIds(thread.parent) - fromParent.dids.forEach((did) => dids.add(did)) - fromParent.uris.forEach((uri) => uris.add(uri)) - } - if (thread.replies) { - for (const reply of thread.replies) { - const fromChild = getRelevantIds(reply) - fromChild.dids.forEach((did) => dids.add(did)) - fromChild.uris.forEach((uri) => uris.add(uri)) - } - } - dids.add(thread.post.postAuthorDid) - uris.add(thread.post.postUri) - if (thread.post.replyRoot) { - // ensure root is included for checking interactions - uris.add(thread.post.replyRoot) - dids.add(new AtUri(thread.post.replyRoot).hostname) - } - return { dids, uris } -} - -const getThreadData = async ( - params: Params, - ctx: Context, -): Promise => { - const { db, feedService } = ctx - const { uri, depth, parentHeight } = params - - const [parents, children] = await Promise.all([ - getAncestorsAndSelfQb(db.db, { uri, parentHeight }) - .selectFrom('ancestor') - .innerJoin( - feedService.selectPostQb().as('post'), - 'post.uri', - 'ancestor.uri', - ) - .selectAll('post') - .execute(), - getDescendentsQb(db.db, { uri, depth }) - .selectFrom('descendent') - .innerJoin( - feedService.selectPostQb().as('post'), - 'post.uri', - 'descendent.uri', - ) - .selectAll('post') - .orderBy('sortAt', 'desc') - .execute(), - ]) - // prevent self-referential loops - const includedPosts = new Set([uri]) - const parentsByUri = parents.reduce((acc, post) => { - return Object.assign(acc, { [post.uri]: post }) - }, {} as Record) - const childrenByParentUri = children.reduce((acc, child) => { - if (!child.replyParent) return acc - if (includedPosts.has(child.uri)) return acc - includedPosts.add(child.uri) - acc[child.replyParent] ??= [] - acc[child.replyParent].push(child) - return acc - }, {} as Record) - const post = parentsByUri[uri] - if (!post) return null - return { - post, - parent: post.replyParent - ? getParentData( - parentsByUri, - includedPosts, - post.replyParent, - parentHeight, - ) - : undefined, - replies: getChildrenData(childrenByParentUri, uri, depth), - } -} - -const getParentData = ( - postsByUri: Record, - includedPosts: Set, - uri: string, - depth: number, -): PostThread | ParentNotFoundError | undefined => { - if (depth < 1) return undefined - if (includedPosts.has(uri)) return undefined - includedPosts.add(uri) - const post = postsByUri[uri] - if (!post) return new ParentNotFoundError(uri) - return { - post, - parent: post.replyParent - ? getParentData(postsByUri, includedPosts, post.replyParent, depth - 1) - : undefined, - replies: [], - } -} - -const getChildrenData = ( - childrenByParentUri: Record, - uri: string, - depth: number, -): PostThread[] | undefined => { - if (depth === 0) return undefined - const children = childrenByParentUri[uri] ?? [] - return children.map((row) => ({ - post: row, - replies: getChildrenData(childrenByParentUri, row.postUri, depth - 1), - })) +const hydration = async ( + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateThreadPosts( + skeleton.uris.map((uri) => ({ uri })), + params.viewer, + ) } -class ParentNotFoundError extends Error { - constructor(public uri: string) { - super(`Parent not found: ${uri}`) +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, params, skeleton, hydration } = inputs + const thread = ctx.views.thread(skeleton, hydration, { + height: params.parentHeight, + depth: params.depth, + }) + if (isNotFoundPost(thread)) { + // @TODO technically this could be returned as a NotFoundPost based on lexicon + throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') } -} - -type PostThread = { - post: FeedRow - parent?: PostThread | ParentNotFoundError - replies?: PostThread[] + return { thread } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views } type Params = QueryParams & { viewer: string | null } -type SkeletonState = { - params: Params - threadData: PostThread +type Skeleton = { + anchor: string + uris: string[] } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index 9db7cf0a252..fc9592ad7f7 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -1,30 +1,19 @@ -import { dedupeStrs } from '@atproto/common' +import { dedupeStrs, mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPosts' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' import { createPipeline } from '../../../../pipeline' -import { ActorService } from '../../../../services/actor' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { creatorFromUri } from '../../../../views/util' export default function (server: Server, ctx: AppContext) { const getPosts = createPipeline(skeleton, hydration, noBlocks, presentation) server.app.bsky.feed.getPosts({ auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) const viewer = auth.credentials.iss - - const results = await getPosts( - { ...params, viewer }, - { db, feedService, actorService }, - ) + const results = await getPosts({ ...params, viewer }, ctx) return { encoding: 'application/json', @@ -34,68 +23,55 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (params: Params, ctx: Context) => { - const deduped = dedupeStrs(params.uris) - const feedItems = await ctx.feedService.postUrisToFeedItems(deduped) - return { params, feedItems } +const skeleton = async (inputs: { params: Params }) => { + return { posts: dedupeStrs(inputs.params.uris) } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydratePosts( + skeleton.posts.map((uri) => ({ uri })), + params.viewer, + ) } -const noBlocks = (state: HydrationState) => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter((item) => { - if (!viewer) return true - return !state.bam.block([viewer, item.postAuthorDid]) +const noBlocks = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + skeleton.posts = skeleton.posts.filter((uri) => { + const creator = creatorFromUri(uri) + return !ctx.views.viewerBlockExists(creator, hydration) }) - return state + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService, actorService } = ctx - const { feedItems, profiles, params } = state - const SKIP = [] - const actors = actorService.views.profileBasicPresentation( - Object.keys(profiles), - state, - params.viewer, +const presentation = (inputs: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const posts = mapDefined(skeleton.posts, (uri) => + ctx.views.post(uri, hydration), ) - const postViews = feedItems.flatMap((item) => { - const postView = feedService.views.formatPostView( - item.postUri, - actors, - state.posts, - state.threadgates, - state.embeds, - state.labels, - state.lists, - params.viewer, - ) - return postView ?? SKIP - }) - return { posts: postViews } + return { posts } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService + hydrator: Hydrator + views: Views } type Params = QueryParams & { viewer: string | null } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + posts: string[] } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts index 7d28014a7b7..fe33e305774 100644 --- a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts @@ -1,14 +1,13 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getRepostedBy' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' -import { notSoftDeletedClause } from '../../../../db/util' -import { Database } from '../../../../db' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { Actor } from '../../../../db/tables/actor' import { createPipeline } from '../../../../pipeline' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { parseString } from '../../../../hydration/util' +import { creatorFromUri } from '../../../../views/util' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { const getRepostedBy = createPipeline( @@ -20,15 +19,8 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getRepostedBy({ auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) const viewer = auth.credentials.iss - - const result = await getRepostedBy( - { ...params, viewer }, - { db, actorService, graphService }, - ) + const result = await getRepostedBy({ ...params, viewer }, ctx) return { encoding: 'application/json', @@ -38,85 +30,78 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db } = ctx - const { limit, cursor, uri, cid } = params - const { ref } = db.db.dynamic - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, repostedBy: [] } - } - - let builder = db.db - .selectFrom('repost') - .where('repost.subject', '=', uri) - .innerJoin('actor as creator', 'creator.did', 'repost.creator') - .where(notSoftDeletedClause(ref('creator'))) - .selectAll('creator') - .select(['repost.cid as cid', 'repost.sortAt as sortAt']) - - if (cid) { - builder = builder.where('repost.subjectCid', '=', cid) +const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { reposts: [] } } - - const keyset = new TimeCidKeyset(ref('repost.sortAt'), ref('repost.cid')) - builder = paginate(builder, { - limit, - cursor, - keyset, + const res = await ctx.hydrator.dataplane.getRepostsBySubject({ + subject: { uri: params.uri, cid: params.cid }, + cursor: params.cursor, + limit: params.limit, }) - - const repostedBy = await builder.execute() - return { params, repostedBy, cursor: keyset.packFromResult(repostedBy) } + return { + reposts: res.uris, + cursor: parseString(res.cursor), + } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, repostedBy } = state - const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles(repostedBy, viewer), - graphService.getBlockAndMuteState( - viewer ? repostedBy.map((item) => [viewer, item.did]) : [], - ), - ]) - return { ...state, bam, actors } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateReposts(skeleton.reposts, params.viewer) } -const noBlocks = (state: HydrationState) => { - const { viewer } = state.params - if (!viewer) return state - state.repostedBy = state.repostedBy.filter( - (item) => !state.bam.block([viewer, item.did]), - ) - return state +const noBlocks = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + skeleton.reposts = skeleton.reposts.filter((uri) => { + const creator = creatorFromUri(uri) + return !ctx.views.viewerBlockExists(creator, hydration) + }) + return skeleton } -const presentation = (state: HydrationState) => { - const { params, repostedBy, actors, cursor } = state - const { uri, cid } = params - const repostedByView = mapDefined(repostedBy, (item) => actors[item.did]) - return { repostedBy: repostedByView, cursor, uri, cid } +const presentation = (inputs: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, params, skeleton, hydration } = inputs + const repostViews = mapDefined(skeleton.reposts, (uri) => { + const repost = hydration.reposts?.get(uri) + if (!repost?.record) { + return + } + const creatorDid = creatorFromUri(uri) + return ctx.views.profile(creatorDid, hydration) + }) + return { + repostedBy: repostViews, + cursor: skeleton.cursor, + uri: params.uri, + cid: params.cid, + } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { viewer: string | null } -type SkeletonState = { - params: Params - repostedBy: Actor[] +type Skeleton = { + reposts: string[] cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap -} diff --git a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts index b96ae2722fa..0524f33b0c0 100644 --- a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -1,37 +1,31 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { parseString } from '../../../../hydration/util' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getSuggestedFeeds({ auth: ctx.authVerifier.standardOptional, - handler: async ({ auth }) => { - // @NOTE ignores cursor, doesn't matter for appview swap + handler: async ({ auth, params }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - const feedsRes = await db.db - .selectFrom('suggested_feed') - .orderBy('suggested_feed.order', 'asc') - .selectAll() - .execute() - const genInfos = await feedService.getFeedGeneratorInfos( - feedsRes.map((r) => r.uri), - viewer, - ) - const genList = feedsRes.map((r) => genInfos[r.uri]).filter(Boolean) - const creators = genList.map((gen) => gen.creator) - const profiles = await actorService.views.profilesBasic(creators, viewer) - const feedViews = mapDefined(genList, (gen) => - feedService.views.formatFeedGeneratorView(gen, profiles), + // @NOTE no need to coordinate the cursor for appview swap, as v1 doesn't use the cursor + const suggestedRes = await ctx.dataplane.getSuggestedFeeds({ + actorDid: viewer ?? undefined, + limit: params.limit, + cursor: params.cursor, + }) + const uris = suggestedRes.uris + const hydration = await ctx.hydrator.hydrateFeedGens(uris, viewer) + const feedViews = mapDefined(uris, (uri) => + ctx.views.feedGenerator(uri, hydration), ) return { encoding: 'application/json', body: { feeds: feedViews, + cursor: parseString(suggestedRes.cursor), }, } }, diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 05ef505ea04..e7e4bed20a9 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -1,18 +1,14 @@ -import { sql } from 'kysely' -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { FeedAlgorithm, FeedKeyset, getFeedDateThreshold } from '../util/feed' -import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { Database } from '../../../../db' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getTimeline' -import { setRepoRev } from '../../../util' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' +import { clearlyBadCursor, setRepoRev } from '../../../util' import { createPipeline } from '../../../../pipeline' +import { HydrationState, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { mapDefined } from '@atproto/common' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getTimeline = createPipeline( @@ -25,15 +21,10 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.standard, handler: async ({ params, auth, res }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica('timeline') - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - const [result, repoRev] = await Promise.all([ - getTimeline({ ...params, viewer }, { db, feedService }), - actorService.getRepoRev(viewer), - ]) + const result = await getTimeline({ ...params, viewer }, ctx) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) setRepoRev(res, repoRev) return { @@ -44,181 +35,78 @@ export default function (server: Server, ctx: AppContext) { }) } -export const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { cursor, limit, algorithm, viewer } = params - const { db } = ctx - const { ref } = db.db.dynamic - - if (algorithm && algorithm !== FeedAlgorithm.ReverseChronological) { - throw new InvalidRequestError(`Unsupported algorithm: ${algorithm}`) - } - - if (limit === 1 && !cursor) { - // special case for limit=1, which is often used to check if there are new items at the top of the timeline. - return skeletonLimit1(params, ctx) - } - - if (FeedKeyset.clearlyBad(cursor)) { - return { params, feedItems: [] } +export const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { items: [] } } - - const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) - const sortFrom = keyset.unpack(cursor)?.primary - - let followQb = db.db - .selectFrom('feed_item') - .innerJoin('follow', 'follow.subjectDid', 'feed_item.originatorDid') - .where('follow.creator', '=', viewer) - .innerJoin('post', 'post.uri', 'feed_item.postUri') - .where('feed_item.sortAt', '>', getFeedDateThreshold(sortFrom, 2)) - .selectAll('feed_item') - .select([ - 'post.replyRoot', - 'post.replyParent', - 'post.creator as postAuthorDid', - ]) - - followQb = paginate(followQb, { - limit, - cursor, - keyset, - tryIndex: true, - }) - - let selfQb = db.db - .selectFrom('feed_item') - .innerJoin('post', 'post.uri', 'feed_item.postUri') - .where('feed_item.originatorDid', '=', viewer) - .where('feed_item.sortAt', '>', getFeedDateThreshold(sortFrom, 2)) - .selectAll('feed_item') - .select([ - 'post.replyRoot', - 'post.replyParent', - 'post.creator as postAuthorDid', - ]) - - selfQb = paginate(selfQb, { - limit: Math.min(limit, 10), - cursor, - keyset, - tryIndex: true, + const res = await ctx.dataplane.getTimeline({ + actorDid: params.viewer, + limit: params.limit, + cursor: params.cursor, }) - - const [followRes, selfRes] = await Promise.all([ - followQb.execute(), - selfQb.execute(), - ]) - - const feedItems: FeedRow[] = [...followRes, ...selfRes] - .sort((a, b) => { - if (a.sortAt > b.sortAt) return -1 - if (a.sortAt < b.sortAt) return 1 - return a.cid > b.cid ? -1 : 1 - }) - .slice(0, limit) - return { - params, - feedItems, - cursor: keyset.packFromResult(feedItems), + items: res.items.map((item) => ({ + post: { uri: item.uri, cid: item.cid || undefined }, + repost: item.repost + ? { uri: item.repost, cid: item.repostCid || undefined } + : undefined, + })), + cursor: parseString(res.cursor), } } -// The limit=1 case is used commonly to check if there are new items at the top of the timeline. -// Since it's so common, it's optimized here. The most common strategy that postgres takes to -// build a timeline is to grab all recent content from each of the user's follow, then paginate it. -// The downside here is that it requires grabbing all recent content from all follows, even if you -// only want a single result. The approach here instead takes the single most recent post from -// each of the user's follows, then sorts only those and takes the top item. -const skeletonLimit1 = async (params: Params, ctx: Context) => { - const { viewer } = params - const { db } = ctx - const { ref } = db.db.dynamic - const creatorsQb = db.db - .selectFrom('follow') - .where('creator', '=', viewer) - .select('subjectDid as did') - .unionAll(sql`select ${viewer} as did`) - const feedItemsQb = db.db - .selectFrom(creatorsQb.as('creator')) - .innerJoinLateral( - (eb) => { - const keyset = new FeedKeyset( - ref('feed_item.sortAt'), - ref('feed_item.cid'), - ) - const creatorFeedItemQb = eb - .selectFrom('feed_item') - .innerJoin('post', 'post.uri', 'feed_item.postUri') - .whereRef('feed_item.originatorDid', '=', 'creator.did') - .where('feed_item.sortAt', '>', getFeedDateThreshold(undefined, 2)) - .selectAll('feed_item') - .select([ - 'post.replyRoot', - 'post.replyParent', - 'post.creator as postAuthorDid', - ]) - return paginate(creatorFeedItemQb, { limit: 1, keyset }).as('result') - }, - (join) => join.onTrue(), - ) - .selectAll('result') - const keyset = new FeedKeyset(ref('result.sortAt'), ref('result.cid')) - const feedItems = await paginate(feedItemsQb, { limit: 1, keyset }).execute() - return { - params, - feedItems, - cursor: keyset.packFromResult(feedItems), - } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}): Promise => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer) } -const hydration = async ( - state: SkeletonState, - ctx: Context, -): Promise => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, +const noBlocksOrMutes = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}): Skeleton => { + const { ctx, skeleton, hydration } = inputs + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) + return ( + !bam.authorBlocked && + !bam.authorMuted && + !bam.originatorBlocked && + !bam.originatorMuted + ) }) - return { ...state, ...hydrated } + return skeleton } -const noBlocksOrMutes = (state: HydrationState): HydrationState => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter( - (item) => - !state.bam.block([viewer, item.postAuthorDid]) && - !state.bam.block([viewer, item.originatorDid]) && - !state.bam.mute([viewer, item.postAuthorDid]) && - !state.bam.mute([viewer, item.originatorDid]), +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), ) - return state -} - -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) - return { feed, cursor } + return { feed, cursor: skeleton.cursor } } type Context = { - db: Database - feedService: FeedService + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient } type Params = QueryParams & { viewer: string } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + items: FeedItem[] cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts index 3ac0b3f9477..876b3113b9f 100644 --- a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts @@ -1,17 +1,20 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' -import { InvalidRequestError } from '@atproto/xrpc-server' import AtpAgent from '@atproto/api' import { mapDefined } from '@atproto/common' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/searchPosts' -import { Database } from '../../../../db' import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { ActorService } from '../../../../services/actor' -import { createPipeline } from '../../../../pipeline' + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { creatorFromUri } from '../../../../views/util' export default function (server: Server, ctx: AppContext) { const searchPosts = createPipeline( @@ -24,19 +27,7 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica('search') - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - const searchAgent = ctx.searchAgent - if (!searchAgent) { - throw new InvalidRequestError('Search not available') - } - - const results = await searchPosts( - { ...params, viewer }, - { db, feedService, actorService, searchAgent }, - ) - + const results = await searchPosts({ ...params, viewer }, ctx) return { encoding: 'application/json', body: results, @@ -45,87 +36,78 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - // @NOTE cursors wont change on appview swap - const res = await ctx.searchAgent.api.app.bsky.unspecced.searchPostsSkeleton({ - q: params.q, - cursor: params.cursor, +const skeleton = async (inputs: SkeletonFnInput) => { + const { ctx, params } = inputs + + if (ctx.searchAgent) { + // @NOTE cursors wont change on appview swap + const { data: res } = + await ctx.searchAgent.api.app.bsky.unspecced.searchPostsSkeleton({ + q: params.q, + cursor: params.cursor, + limit: params.limit, + }) + return { + posts: res.posts.map(({ uri }) => uri), + cursor: parseString(res.cursor), + } + } + + const res = await ctx.dataplane.searchPosts({ + term: params.q, limit: params.limit, + cursor: params.cursor, }) - const postUris = res.data.posts.map((a) => a.uri) - const feedItems = await ctx.feedService.postUrisToFeedItems(postUris) return { - params, - feedItems, - cursor: res.data.cursor, - hitsTotal: res.data.hitsTotal, + posts: res.uris, + cursor: parseString(res.cursor), } } const hydration = async ( - state: SkeletonState, - ctx: Context, -): Promise => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydratePosts( + skeleton.posts.map((uri) => ({ uri })), + params.viewer, + ) } -const noBlocks = (state: HydrationState): HydrationState => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter((item) => { - if (!viewer) return true - return !state.bam.block([viewer, item.postAuthorDid]) +const noBlocks = (inputs: RulesFnInput) => { + const { ctx, skeleton, hydration } = inputs + skeleton.posts = skeleton.posts.filter((uri) => { + const creator = creatorFromUri(uri) + return !ctx.views.viewerBlockExists(creator, hydration) }) - return state + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService, actorService } = ctx - const { feedItems, profiles, params } = state - const actors = actorService.views.profileBasicPresentation( - Object.keys(profiles), - state, - params.viewer, - ) - - const postViews = mapDefined(feedItems, (item) => - feedService.views.formatPostView( - item.postUri, - actors, - state.posts, - state.threadgates, - state.embeds, - state.labels, - state.lists, - params.viewer, - ), +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = inputs + const posts = mapDefined(skeleton.posts, (uri) => + ctx.views.post(uri, hydration), ) - return { posts: postViews, cursor: state.cursor, hitsTotal: state.hitsTotal } + return { + posts, + cursor: skeleton.cursor, + hitsTotal: skeleton.hitsTotal, + } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService - searchAgent: AtpAgent + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views + searchAgent?: AtpAgent } type Params = QueryParams & { viewer: string | null } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + posts: string[] hitsTotal?: number cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts index adc28752a25..1df10bf5a23 100644 --- a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts @@ -1,54 +1,84 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getBlocks' import AppContext from '../../../../context' -import { notSoftDeletedClause } from '../../../../db/util' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { + const getBlocks = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.graph.getBlocks({ auth: ctx.authVerifier.standard, handler: async ({ params, auth }) => { - const { limit, cursor } = params - const requester = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { blocks: [] }, - } + const viewer = auth.credentials.iss + const result = await getBlocks({ ...params, viewer }, ctx) + return { + encoding: 'application/json', + body: result, } + }, + }) +} - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + if (clearlyBadCursor(params.cursor)) { + return { blockedDids: [] } + } + const { blockUris, cursor } = await ctx.hydrator.dataplane.getBlocks({ + actorDid: params.viewer, + cursor: params.cursor, + limit: params.limit, + }) + const blocks = await ctx.hydrator.graph.getBlocks(blockUris) + const blockedDids = mapDefined( + blockUris, + (uri) => blocks.get(uri)?.record.subject, + ) + return { + blockedDids, + cursor: cursor || undefined, + } +} - let blocksReq = db.db - .selectFrom('actor_block') - .where('actor_block.creator', '=', requester) - .innerJoin('actor as subject', 'subject.did', 'actor_block.subjectDid') - .where(notSoftDeletedClause(ref('subject'))) - .selectAll('subject') - .select(['actor_block.cid as cid', 'actor_block.sortAt as sortAt']) +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { viewer } = params + const { blockedDids } = skeleton + return ctx.hydrator.hydrateProfiles(blockedDids, viewer) +} - const keyset = new TimeCidKeyset( - ref('actor_block.sortAt'), - ref('actor_block.cid'), - ) - blocksReq = paginate(blocksReq, { - limit, - cursor, - keyset, - }) +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton } = input + const { blockedDids, cursor } = skeleton + const blocks = mapDefined(blockedDids, (did) => { + return ctx.views.profile(did, hydration) + }) + return { blocks, cursor } +} - const blocksRes = await blocksReq.execute() +type Context = { + hydrator: Hydrator + views: Views +} - const actorService = ctx.services.actor(db) - const blocks = await actorService.views.profilesList(blocksRes, requester) +type Params = QueryParams & { + viewer: string +} - return { - encoding: 'application/json', - body: { - blocks, - cursor: keyset.packFromResult(blocksRes), - }, - } - }, - }) +type SkeletonState = { + blockedDids: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts index bf22b2be6cb..c771d034cfd 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -3,32 +3,33 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollowers' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { notSoftDeletedClause } from '../../../../db/util' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' -import { Actor } from '../../../../db/tables/actor' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { createPipeline } from '../../../../pipeline' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { didFromUri } from '../../../../hydration/util' +import { Hydrator, mergeStates } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { const getFollowers = createPipeline( skeleton, hydration, - noBlocksInclInvalid, + noBlocks, presentation, ) server.app.bsky.graph.getFollowers({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) const result = await getFollowers( { ...params, viewer, canViewTakedowns }, - { db, actorService, graphService }, + ctx, ) return { @@ -39,95 +40,86 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db, actorService } = ctx - const { limit, cursor, actor, canViewTakedowns } = params - const { ref } = db.db.dynamic - - const subject = await actorService.getActor(actor, canViewTakedowns) - if (!subject) { - throw new InvalidRequestError(`Actor not found: ${actor}`) +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + const [subjectDid] = await ctx.hydrator.actor.getDidsDefined([params.actor]) + if (!subjectDid) { + throw new InvalidRequestError(`Actor not found: ${params.actor}`) } - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, followers: [], subject } + if (clearlyBadCursor(params.cursor)) { + return { subjectDid, followUris: [] } } - - let followersReq = db.db - .selectFrom('follow') - .where('follow.subjectDid', '=', subject.did) - .innerJoin('actor as creator', 'creator.did', 'follow.creator') - .if(!canViewTakedowns, (qb) => - qb.where(notSoftDeletedClause(ref('creator'))), - ) - .selectAll('creator') - .select(['follow.cid as cid', 'follow.sortAt as sortAt']) - - const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) - followersReq = paginate(followersReq, { - limit, - cursor, - keyset, + const { followers, cursor } = await ctx.hydrator.graph.getActorFollowers({ + did: subjectDid, + cursor: params.cursor, + limit: params.limit, }) - - const followers = await followersReq.execute() return { - params, - followers, - subject, - cursor: keyset.packFromResult(followers), + subjectDid, + followUris: followers.map((f) => f.uri), + cursor: cursor || undefined, } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, followers, subject } = state +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles([subject, ...followers], viewer), - graphService.getBlockAndMuteState( - followers.flatMap((item) => { - if (viewer) { - return [ - [viewer, item.did], - [subject.did, item.did], - ] - } - return [[subject.did, item.did]] - }), - ), - ]) - return { ...state, bam, actors } + const { followUris, subjectDid } = skeleton + const followState = await ctx.hydrator.hydrateFollows(followUris) + const dids = [subjectDid] + if (followState.follows) { + for (const [uri, follow] of followState.follows) { + if (follow) { + dids.push(didFromUri(uri)) + } + } + } + const profileState = await ctx.hydrator.hydrateProfiles(dids, viewer) + return mergeStates(followState, profileState) } -const noBlocksInclInvalid = (state: HydrationState) => { - const { subject } = state - const { viewer } = state.params - state.followers = state.followers.filter( - (item) => - !state.bam.block([subject.did, item.did]) && - (!viewer || !state.bam.block([viewer, item.did])), - ) - return state +const noBlocks = (input: RulesFnInput) => { + const { skeleton, params, hydration, ctx } = input + const { viewer } = params + skeleton.followUris = skeleton.followUris.filter((followUri) => { + const followerDid = didFromUri(followUri) + return ( + !hydration.followBlocks?.get(followUri) && + (!viewer || !ctx.views.viewerBlockExists(followerDid, hydration)) + ) + }) + return skeleton } -const presentation = (state: HydrationState) => { - const { params, followers, subject, actors, cursor } = state - const subjectView = actors[subject.did] - const followersView = mapDefined(followers, (item) => actors[item.did]) - if (!subjectView) { +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton, params } = input + const { subjectDid, followUris, cursor } = skeleton + const isTakendown = (did: string) => + ctx.views.actorIsTakendown(did, hydration) + + const subject = ctx.views.profile(subjectDid, hydration) + if (!subject || (!params.canViewTakedowns && isTakendown(subjectDid))) { throw new InvalidRequestError(`Actor not found: ${params.actor}`) } - return { followers: followersView, subject: subjectView, cursor } + + const followers = mapDefined(followUris, (followUri) => { + const followerDid = didFromUri(followUri) + if (!params.canViewTakedowns && isTakendown(followerDid)) { + return + } + return ctx.views.profile(didFromUri(followUri), hydration) + }) + + return { followers, subject, cursor } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { @@ -136,13 +128,7 @@ type Params = QueryParams & { } type SkeletonState = { - params: Params - followers: Actor[] - subject: Actor + subjectDid: string + followUris: string[] cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap -} diff --git a/packages/bsky/src/api/app/bsky/graph/getFollows.ts b/packages/bsky/src/api/app/bsky/graph/getFollows.ts index 8d294b27354..81df38e453e 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -1,34 +1,30 @@ import { mapDefined } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollows' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollowers' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { notSoftDeletedClause } from '../../../../db/util' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' -import { Actor } from '../../../../db/tables/actor' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { createPipeline } from '../../../../pipeline' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { Hydrator, mergeStates } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { - const getFollows = createPipeline( - skeleton, - hydration, - noBlocksInclInvalid, - presentation, - ) + const getFollows = createPipeline(skeleton, hydration, noBlocks, presentation) server.app.bsky.graph.getFollows({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) + // @TODO ensure canViewTakedowns gets threaded through and applied properly const result = await getFollows( { ...params, viewer, canViewTakedowns }, - { db, actorService, graphService }, + ctx, ) return { @@ -39,96 +35,89 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db, actorService } = ctx - const { limit, cursor, actor, canViewTakedowns } = params - const { ref } = db.db.dynamic - - const creator = await actorService.getActor(actor, canViewTakedowns) - if (!creator) { - throw new InvalidRequestError(`Actor not found: ${actor}`) +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + const [subjectDid] = await ctx.hydrator.actor.getDidsDefined([params.actor]) + if (!subjectDid) { + throw new InvalidRequestError(`Actor not found: ${params.actor}`) } - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, follows: [], creator } + if (clearlyBadCursor(params.cursor)) { + return { subjectDid, followUris: [] } } - - let followsReq = db.db - .selectFrom('follow') - .where('follow.creator', '=', creator.did) - .innerJoin('actor as subject', 'subject.did', 'follow.subjectDid') - .if(!canViewTakedowns, (qb) => - qb.where(notSoftDeletedClause(ref('subject'))), - ) - .selectAll('subject') - .select(['follow.cid as cid', 'follow.sortAt as sortAt']) - - const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) - followsReq = paginate(followsReq, { - limit, - cursor, - keyset, + const { follows, cursor } = await ctx.hydrator.graph.getActorFollows({ + did: subjectDid, + cursor: params.cursor, + limit: params.limit, }) - - const follows = await followsReq.execute() - return { - params, - follows, - creator, - cursor: keyset.packFromResult(follows), + subjectDid, + followUris: follows.map((f) => f.uri), + cursor: cursor || undefined, } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, follows, creator } = state +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles([creator, ...follows], viewer), - graphService.getBlockAndMuteState( - follows.flatMap((item) => { - if (viewer) { - return [ - [viewer, item.did], - [creator.did, item.did], - ] - } - return [[creator.did, item.did]] - }), - ), - ]) - return { ...state, bam, actors } + const { followUris, subjectDid } = skeleton + const followState = await ctx.hydrator.hydrateFollows(followUris) + const dids = [subjectDid] + if (followState.follows) { + for (const follow of followState.follows.values()) { + if (follow) { + dids.push(follow.record.subject) + } + } + } + const profileState = await ctx.hydrator.hydrateProfiles(dids, viewer) + return mergeStates(followState, profileState) } -const noBlocksInclInvalid = (state: HydrationState) => { - const { creator } = state - const { viewer } = state.params - state.follows = state.follows.filter( - (item) => - !state.bam.block([creator.did, item.did]) && - (!viewer || !state.bam.block([viewer, item.did])), - ) - return state +const noBlocks = (input: RulesFnInput) => { + const { skeleton, params, hydration, ctx } = input + const { viewer } = params + skeleton.followUris = skeleton.followUris.filter((followUri) => { + const follow = hydration.follows?.get(followUri) + if (!follow) return false + return ( + !hydration.followBlocks?.get(followUri) && + (!viewer || + !ctx.views.viewerBlockExists(follow.record.subject, hydration)) + ) + }) + return skeleton } -const presentation = (state: HydrationState) => { - const { params, follows, creator, actors, cursor } = state - const creatorView = actors[creator.did] - const followsView = mapDefined(follows, (item) => actors[item.did]) - if (!creatorView) { +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton, params } = input + const { subjectDid, followUris, cursor } = skeleton + const isTakendown = (did: string) => + ctx.views.actorIsTakendown(did, hydration) + + const subject = ctx.views.profile(subjectDid, hydration) + if (!subject || (!params.canViewTakedowns && isTakendown(subjectDid))) { throw new InvalidRequestError(`Actor not found: ${params.actor}`) } - return { follows: followsView, subject: creatorView, cursor } + + const follows = mapDefined(followUris, (followUri) => { + const followDid = hydration.follows?.get(followUri)?.record.subject + if (!followDid) return + if (!params.canViewTakedowns && isTakendown(followDid)) { + return + } + return ctx.views.profile(followDid, hydration) + }) + + return { follows, subject, cursor } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { @@ -137,13 +126,7 @@ type Params = QueryParams & { } type SkeletonState = { - params: Params - follows: Actor[] - creator: Actor + subjectDid: string + followUris: string[] cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap -} diff --git a/packages/bsky/src/api/app/bsky/graph/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index 82007b45388..e8204d4ac0d 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -3,28 +3,25 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getList' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' -import { Actor } from '../../../../db/tables/actor' -import { GraphService, ListInfo } from '../../../../services/graph' -import { ActorService, ProfileHydrationState } from '../../../../services/actor' -import { createPipeline, noRules } from '../../../../pipeline' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { Hydrator, mergeStates } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor } from '../../../util' +import { ListItemInfo } from '../../../../proto/bsky_pb' export default function (server: Server, ctx: AppContext) { const getList = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.graph.getList({ auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const graphService = ctx.services.graph(db) - const actorService = ctx.services.actor(db) const viewer = auth.credentials.iss - - const result = await getList( - { ...params, viewer }, - { db, graphService, actorService }, - ) - + const result = await getList({ ...params, viewer }, ctx) return { encoding: 'application/json', body: result, @@ -34,89 +31,60 @@ export default function (server: Server, ctx: AppContext) { } const skeleton = async ( - params: Params, - ctx: Context, + input: SkeletonFnInput, ): Promise => { - const { db, graphService } = ctx - const { list, limit, cursor, viewer } = params - const { ref } = db.db.dynamic - - const listRes = await graphService - .getListsQb(viewer) - .where('list.uri', '=', list) - .executeTakeFirst() - if (!listRes) { - throw new InvalidRequestError(`List not found: ${list}`) - } - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, list: listRes, listItems: [] } + const { ctx, params } = input + if (clearlyBadCursor(params.cursor)) { + return { listUri: params.list, listitems: [] } } - - let itemsReq = graphService - .getListItemsQb() - .where('list_item.listUri', '=', list) - .where('list_item.creator', '=', listRes.creator) - - const keyset = new TimeCidKeyset( - ref('list_item.sortAt'), - ref('list_item.cid'), - ) - - itemsReq = paginate(itemsReq, { - limit, - cursor, - keyset, + const { listitems, cursor } = await ctx.hydrator.dataplane.getListMembers({ + listUri: params.list, + limit: params.limit, + cursor: params.cursor, }) - - const listItems = await itemsReq.execute() - return { - params, - list: listRes, - listItems, - cursor: keyset.packFromResult(listItems), + listUri: params.list, + listitems, + cursor: cursor || undefined, } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const { params, list, listItems } = state - const profileState = await actorService.views.profileHydration( - [list, ...listItems].map((x) => x.did), - { viewer: params.viewer }, - ) - return { ...state, ...profileState } +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { viewer } = params + const { listUri, listitems } = skeleton + const [listState, profileState] = await Promise.all([ + ctx.hydrator.hydrateLists([listUri], viewer), + ctx.hydrator.hydrateProfiles( + listitems.map(({ did }) => did), + viewer, + ), + ]) + return mergeStates(listState, profileState) } -const presentation = (state: HydrationState, ctx: Context) => { - const { actorService, graphService } = ctx - const { params, list, listItems, cursor, ...profileState } = state - const actors = actorService.views.profilePresentation( - Object.keys(profileState.profiles), - profileState, - params.viewer, - ) - const creator = actors[list.creator] - if (!creator) { - throw new InvalidRequestError(`Actor not found: ${list.handle}`) - } - const listView = graphService.formatListView(list, actors) - if (!listView) { - throw new InvalidRequestError('List not found') - } - const items = mapDefined(listItems, (item) => { - const subject = actors[item.did] +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + const { listUri, listitems, cursor } = skeleton + const list = ctx.views.list(listUri, hydration) + const items = mapDefined(listitems, ({ uri, did }) => { + const subject = ctx.views.profile(did, hydration) if (!subject) return - return { uri: item.uri, subject } + return { uri, subject } }) - return { list: listView, items, cursor } + if (!list) { + throw new InvalidRequestError('List not found') + } + return { list, items, cursor } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { @@ -124,10 +92,7 @@ type Params = QueryParams & { } type SkeletonState = { - params: Params - list: Actor & ListInfo - listItems: (Actor & { uri: string; cid: string; sortAt: string })[] + listUri: string + listitems: ListItemInfo[] cursor?: string } - -type HydrationState = SkeletonState & ProfileHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts index 6fb6df55e82..ae4a9a27b59 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts @@ -1,13 +1,17 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getListBlocks' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { Actor } from '../../../../db/tables/actor' -import { GraphService, ListInfo } from '../../../../services/graph' -import { ActorService, ProfileHydrationState } from '../../../../services/actor' -import { createPipeline, noRules } from '../../../../pipeline' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { const getListBlocks = createPipeline( @@ -19,16 +23,8 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListBlocks({ auth: ctx.authVerifier.standard, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const graphService = ctx.services.graph(db) - const actorService = ctx.services.actor(db) const viewer = auth.credentials.iss - - const result = await getListBlocks( - { ...params, viewer }, - { db, actorService, graphService }, - ) - + const result = await getListBlocks({ ...params, viewer }, ctx) return { encoding: 'application/json', body: result, @@ -38,72 +34,40 @@ export default function (server: Server, ctx: AppContext) { } const skeleton = async ( - params: Params, - ctx: Context, + input: SkeletonFnInput, ): Promise => { - const { db, graphService } = ctx - const { limit, cursor, viewer } = params - const { ref } = db.db.dynamic - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, listInfos: [] } - } - - let listsReq = graphService - .getListsQb(viewer) - .whereExists( - db.db - .selectFrom('list_block') - .where('list_block.creator', '=', viewer) - .whereRef('list_block.subjectUri', '=', ref('list.uri')) - .selectAll(), - ) - - const keyset = new TimeCidKeyset(ref('list.createdAt'), ref('list.cid')) - - listsReq = paginate(listsReq, { - limit, - cursor, - keyset, - }) - - const listInfos = await listsReq.execute() - - return { - params, - listInfos, - cursor: keyset.packFromResult(listInfos), + const { ctx, params } = input + if (clearlyBadCursor(params.cursor)) { + return { listUris: [] } } + const { listUris, cursor } = + await ctx.hydrator.dataplane.getBlocklistSubscriptions({ + actorDid: params.viewer, + cursor: params.cursor, + limit: params.limit, + }) + return { listUris, cursor: cursor || undefined } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const { params, listInfos } = state - const profileState = await actorService.views.profileHydration( - listInfos.map((list) => list.creator), - { viewer: params.viewer }, - ) - return { ...state, ...profileState } +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + return await ctx.hydrator.hydrateLists(skeleton.listUris, params.viewer) } -const presentation = (state: HydrationState, ctx: Context) => { - const { actorService, graphService } = ctx - const { params, listInfos, cursor, ...profileState } = state - const actors = actorService.views.profilePresentation( - Object.keys(profileState.profiles), - profileState, - params.viewer, - ) - const lists = mapDefined(listInfos, (list) => - graphService.formatListView(list, actors), - ) +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + const { listUris, cursor } = skeleton + const lists = mapDefined(listUris, (uri) => ctx.views.list(uri, hydration)) return { lists, cursor } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { @@ -111,9 +75,6 @@ type Params = QueryParams & { } type SkeletonState = { - params: Params - listInfos: (Actor & ListInfo)[] + listUris: string[] cursor?: string } - -type HydrationState = SkeletonState & ProfileHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts index 8baa509ae47..20060a5b9a2 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts @@ -1,58 +1,80 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getListBlocks' import AppContext from '../../../../context' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { + const getListMutes = createPipeline( + skeleton, + hydration, + noRules, + presentation, + ) server.app.bsky.graph.getListMutes({ auth: ctx.authVerifier.standard, handler: async ({ params, auth }) => { - const { limit, cursor } = params - const requester = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { lists: [] }, - } + const viewer = auth.credentials.iss + const result = await getListMutes({ ...params, viewer }, ctx) + return { + encoding: 'application/json', + body: result, } + }, + }) +} - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - - const graphService = ctx.services.graph(db) +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { ctx, params } = input + if (clearlyBadCursor(params.cursor)) { + return { listUris: [] } + } + const { listUris, cursor } = + await ctx.hydrator.dataplane.getMutelistSubscriptions({ + actorDid: params.viewer, + cursor: params.cursor, + limit: params.limit, + }) + return { listUris, cursor: cursor || undefined } +} - let listsReq = graphService - .getListsQb(requester) - .whereExists( - db.db - .selectFrom('list_mute') - .where('list_mute.mutedByDid', '=', requester) - .whereRef('list_mute.listUri', '=', ref('list.uri')) - .selectAll(), - ) +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + return await ctx.hydrator.hydrateLists(skeleton.listUris, params.viewer) +} - const keyset = new TimeCidKeyset(ref('list.createdAt'), ref('list.cid')) - listsReq = paginate(listsReq, { - limit, - cursor, - keyset, - }) - const listsRes = await listsReq.execute() +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + const { listUris, cursor } = skeleton + const lists = mapDefined(listUris, (uri) => ctx.views.list(uri, hydration)) + return { lists, cursor } +} - const actorService = ctx.services.actor(db) - const profiles = await actorService.views.profiles(listsRes, requester) +type Context = { + hydrator: Hydrator + views: Views +} - const lists = mapDefined(listsRes, (row) => - graphService.formatListView(row, profiles), - ) +type Params = QueryParams & { + viewer: string +} - return { - encoding: 'application/json', - body: { - lists, - cursor: keyset.packFromResult(listsRes), - }, - } - }, - }) +type SkeletonState = { + listUris: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getLists.ts b/packages/bsky/src/api/app/bsky/graph/getLists.ts index 40bb903f5b4..0ff90f5c4bf 100644 --- a/packages/bsky/src/api/app/bsky/graph/getLists.ts +++ b/packages/bsky/src/api/app/bsky/graph/getLists.ts @@ -1,63 +1,79 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getLists' import AppContext from '../../../../context' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { + const getLists = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.graph.getLists({ auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth }) => { - const { actor, limit, cursor } = params - const requester = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { lists: [] }, - } + const viewer = auth.credentials.iss + const result = await getLists({ ...params, viewer }, ctx) + + return { + encoding: 'application/json', + body: result, } + }, + }) +} - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { ctx, params } = input + if (clearlyBadCursor(params.cursor)) { + return { listUris: [] } + } + const { listUris, cursor } = await ctx.hydrator.dataplane.getActorLists({ + actorDid: params.actor, + cursor: params.cursor, + limit: params.limit, + }) + return { listUris, cursor: cursor || undefined } +} - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { viewer } = params + const { listUris } = skeleton + return ctx.hydrator.hydrateLists(listUris, viewer) +} - const creatorRes = await actorService.getActor(actor) - if (!creatorRes) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + const { listUris, cursor } = skeleton + const lists = mapDefined(listUris, (uri) => { + return ctx.views.list(uri, hydration) + }) + return { lists, cursor } +} - let listsReq = graphService - .getListsQb(requester) - .where('list.creator', '=', creatorRes.did) - - const keyset = new TimeCidKeyset(ref('list.sortAt'), ref('list.cid')) - listsReq = paginate(listsReq, { - limit, - cursor, - keyset, - }) - - const [listsRes, profiles] = await Promise.all([ - listsReq.execute(), - actorService.views.profiles([creatorRes], requester), - ]) - if (!profiles[creatorRes.did]) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } +type Context = { + hydrator: Hydrator + views: Views +} - const lists = mapDefined(listsRes, (row) => - graphService.formatListView(row, profiles), - ) +type Params = QueryParams & { + viewer: string | null +} - return { - encoding: 'application/json', - body: { - lists, - cursor: keyset.packFromResult(listsRes), - }, - } - }, - }) +type SkeletonState = { + listUris: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getMutes.ts b/packages/bsky/src/api/app/bsky/graph/getMutes.ts index 827573258bd..3abd417eb87 100644 --- a/packages/bsky/src/api/app/bsky/graph/getMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getMutes.ts @@ -1,62 +1,79 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getMutes' import AppContext from '../../../../context' -import { notSoftDeletedClause } from '../../../../db/util' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { + HydrationFnInput, + PresentationFnInput, + SkeletonFnInput, + createPipeline, + noRules, +} from '../../../../pipeline' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { + const getMutes = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.graph.getMutes({ auth: ctx.authVerifier.standard, handler: async ({ params, auth }) => { - const { limit, cursor } = params - const requester = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { mutes: [] }, - } - } - - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - - let mutesReq = db.db - .selectFrom('mute') - .innerJoin('actor', 'actor.did', 'mute.subjectDid') - .where(notSoftDeletedClause(ref('actor'))) - .where('mute.mutedByDid', '=', requester) - .selectAll('actor') - .select('mute.createdAt as createdAt') - - const keyset = new CreatedAtDidKeyset( - ref('mute.createdAt'), - ref('mute.subjectDid'), - ) - mutesReq = paginate(mutesReq, { - limit, - cursor, - keyset, - }) - - const mutesRes = await mutesReq.execute() - - const actorService = ctx.services.actor(db) - + const viewer = auth.credentials.iss + const result = await getMutes({ ...params, viewer }, ctx) return { encoding: 'application/json', - body: { - cursor: keyset.packFromResult(mutesRes), - mutes: await actorService.views.profilesList(mutesRes, requester), - }, + body: result, } }, }) } -export class CreatedAtDidKeyset extends TimeCidKeyset<{ - createdAt: string - did: string // dids are treated identically to cids in TimeCidKeyset -}> { - labelResult(result: { createdAt: string; did: string }) { - return { primary: result.createdAt, secondary: result.did } +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + if (clearlyBadCursor(params.cursor)) { + return { mutedDids: [] } } + const { dids, cursor } = await ctx.hydrator.dataplane.getMutes({ + actorDid: params.viewer, + cursor: params.cursor, + limit: params.limit, + }) + return { + mutedDids: dids, + cursor: cursor || undefined, + } +} + +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { viewer } = params + const { mutedDids } = skeleton + return ctx.hydrator.hydrateProfiles(mutedDids, viewer) +} + +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton } = input + const { mutedDids, cursor } = skeleton + const mutes = mapDefined(mutedDids, (did) => { + return ctx.views.profile(did, hydration) + }) + return { mutes, cursor } +} + +type Context = { + hydrator: Hydrator + views: Views +} + +type Params = QueryParams & { + viewer: string +} + +type SkeletonState = { + mutedDids: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getRelationships.ts b/packages/bsky/src/api/app/bsky/graph/getRelationships.ts index 5ac4f3107c4..47aaa6cd083 100644 --- a/packages/bsky/src/api/app/bsky/graph/getRelationships.ts +++ b/packages/bsky/src/api/app/bsky/graph/getRelationships.ts @@ -1,6 +1,5 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { Relationship } from '../../../../lexicon/types/app/bsky/graph/defs' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getRelationships({ @@ -15,42 +14,18 @@ export default function (server: Server, ctx: AppContext) { }, } } - const db = ctx.db.getPrimary() - const { ref } = db.db.dynamic - const res = await db.db - .selectFrom('actor') - .select([ - 'actor.did', - db.db - .selectFrom('follow') - .where('creator', '=', actor) - .whereRef('subjectDid', '=', ref('actor.did')) - .select('uri') - .as('following'), - db.db - .selectFrom('follow') - .whereRef('creator', '=', ref('actor.did')) - .where('subjectDid', '=', actor) - .select('uri') - .as('followedBy'), - ]) - .where('actor.did', 'in', others) - .execute() - - const relationshipsMap = res.reduce((acc, cur) => { - return acc.set(cur.did, { - did: cur.did, - following: cur.following ?? undefined, - followedBy: cur.followedBy ?? undefined, - }) - }, new Map()) - + const res = await ctx.hydrator.actor.getProfileViewerStatesNaive( + others, + actor, + ) const relationships = others.map((did) => { - const relationship = relationshipsMap.get(did) - return relationship + const subject = res.get(did) + return subject ? { $type: 'app.bsky.graph.defs#relationship', - ...relationship, + did, + following: subject.following, + followedBy: subject.followedBy, } : { $type: 'app.bsky.graph.defs#notFoundActor', @@ -58,7 +33,6 @@ export default function (server: Server, ctx: AppContext) { notFound: true, } }) - return { encoding: 'application/json', body: { diff --git a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index 3aec8ded48e..49c4a61c1c8 100644 --- a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -1,139 +1,98 @@ -import { sql } from 'kysely' +import { mapDefined } from '@atproto/common' +import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getSuggestedFollowsByActor' import AppContext from '../../../../context' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { Database } from '../../../../db' -import { ActorService } from '../../../../services/actor' - -const RESULT_LENGTH = 10 +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' export default function (server: Server, ctx: AppContext) { + const getSuggestedFollowsByActor = createPipeline( + skeleton, + hydration, + noBlocksOrMutes, + presentation, + ) server.app.bsky.graph.getSuggestedFollowsByActor({ auth: ctx.authVerifier.standard, handler: async ({ auth, params }) => { - const { actor } = params const viewer = auth.credentials.iss - - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const actorDid = await actorService.getActorDid(actor) - - if (!actorDid) { - throw new InvalidRequestError('Actor not found') - } - - const skeleton = await getSkeleton( - { - actor: actorDid, - viewer, - }, - { - db, - actorService, - }, - ) - const hydrationState = await actorService.views.profileDetailHydration( - skeleton.map((a) => a.did), - { viewer }, - ) - const presentationState = actorService.views.profileDetailPresentation( - skeleton.map((a) => a.did), - hydrationState, - { viewer }, + const result = await getSuggestedFollowsByActor( + { ...params, viewer }, + ctx, ) - const suggestions = Object.values(presentationState).filter((profile) => { - return ( - !profile.viewer?.muted && - !profile.viewer?.mutedByList && - !profile.viewer?.blocking && - !profile.viewer?.blockedBy - ) - }) - return { encoding: 'application/json', - body: { suggestions }, + body: result, } }, }) } -async function getSkeleton( - params: { - actor: string - viewer: string - }, - ctx: { - db: Database - actorService: ActorService - }, -): Promise<{ did: string }[]> { - const actorsViewerFollows = ctx.db.db - .selectFrom('follow') - .where('creator', '=', params.viewer) - .select('subjectDid') - const mostLikedAccounts = await ctx.db.db - .selectFrom( - ctx.db.db - .selectFrom('like') - .where('creator', '=', params.actor) - .select(sql`split_part(subject, '/', 3)`.as('subjectDid')) - .orderBy('sortAt', 'desc') - .limit(1000) // limit to 1000 - .as('likes'), - ) - .select('likes.subjectDid as did') - .select((qb) => qb.fn.count('likes.subjectDid').as('count')) - .where('likes.subjectDid', 'not in', actorsViewerFollows) - .where('likes.subjectDid', 'not in', [params.actor, params.viewer]) - .groupBy('likes.subjectDid') - .orderBy('count', 'desc') - .limit(RESULT_LENGTH) - .execute() - const resultDids = mostLikedAccounts.map((a) => ({ did: a.did })) as { - did: string - }[] +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + const [relativeToDid] = await ctx.hydrator.actor.getDids([params.actor]) + if (!relativeToDid) { + throw new InvalidRequestError('Actor not found') + } + const { dids, cursor } = await ctx.hydrator.dataplane.getFollowSuggestions({ + actorDid: params.viewer, + relativeToDid, + }) + return { + suggestedDids: dids, + cursor: cursor || undefined, + } +} + +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { viewer } = params + const { suggestedDids } = skeleton + return ctx.hydrator.hydrateProfilesDetailed(suggestedDids, viewer) +} - if (resultDids.length < RESULT_LENGTH) { - // backfill with popular accounts followed by actor - const mostPopularAccountsActorFollows = await ctx.db.db - .selectFrom('follow') - .innerJoin('profile_agg', 'follow.subjectDid', 'profile_agg.did') - .select('follow.subjectDid as did') - .where('follow.creator', '=', params.actor) - .where('follow.subjectDid', '!=', params.viewer) - .where('follow.subjectDid', 'not in', actorsViewerFollows) - .if(resultDids.length > 0, (qb) => - qb.where( - 'subjectDid', - 'not in', - resultDids.map((a) => a.did), - ), - ) - .orderBy('profile_agg.followersCount', 'desc') - .limit(RESULT_LENGTH) - .execute() +const noBlocksOrMutes = ( + input: RulesFnInput, +) => { + const { ctx, skeleton, hydration } = input + skeleton.suggestedDids = skeleton.suggestedDids.filter( + (did) => + !ctx.views.viewerBlockExists(did, hydration) && + !ctx.views.viewerMuteExists(did, hydration), + ) + return skeleton +} - resultDids.push(...mostPopularAccountsActorFollows) - } +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton } = input + const { suggestedDids } = skeleton + const suggestions = mapDefined(suggestedDids, (did) => + ctx.views.profileDetailed(did, hydration), + ) + return { suggestions } +} - if (resultDids.length < RESULT_LENGTH) { - // backfill with suggested_follow table - const additional = await ctx.db.db - .selectFrom('suggested_follow') - .where( - 'did', - 'not in', - // exclude any we already have - resultDids.map((a) => a.did).concat([params.actor, params.viewer]), - ) - // and aren't already followed by viewer - .where('did', 'not in', actorsViewerFollows) - .selectAll() - .execute() +type Context = { + hydrator: Hydrator + views: Views +} - resultDids.push(...additional) - } +type Params = QueryParams & { + viewer: string +} - return resultDids +type SkeletonState = { + suggestedDids: string[] } diff --git a/packages/bsky/src/api/app/bsky/graph/muteActor.ts b/packages/bsky/src/api/app/bsky/graph/muteActor.ts index 72a1635c55d..65600344a8d 100644 --- a/packages/bsky/src/api/app/bsky/graph/muteActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/muteActor.ts @@ -1,9 +1,7 @@ -import assert from 'node:assert' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { MuteOperation_Type } from '../../../../proto/bsync_pb' -import { BsyncClient } from '../../../../bsync' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActor({ @@ -11,44 +9,13 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ req, auth, input }) => { const { actor } = input.body const requester = auth.credentials.iss - const db = ctx.db.getPrimary() - - const subjectDid = await ctx.services.actor(db).getActorDid(actor) - if (!subjectDid) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } - if (subjectDid === requester) { - throw new InvalidRequestError('Cannot mute oneself') - } - - const muteActor = async () => { - await ctx.services.graph(db).muteActor({ - subjectDid, - mutedByDid: requester, - }) - } - - const addBsyncMuteOp = async (bsyncClient: BsyncClient) => { - await bsyncClient.addMuteOperation({ - type: MuteOperation_Type.ADD, - actorDid: requester, - subject: subjectDid, - }) - } - - if (ctx.cfg.bsyncOnlyMutes) { - assert(ctx.bsyncClient) - await addBsyncMuteOp(ctx.bsyncClient) - } else { - await muteActor() - if (ctx.bsyncClient) { - try { - await addBsyncMuteOp(ctx.bsyncClient) - } catch (err) { - req.log.warn(err, 'failed to sync mute op to bsync') - } - } - } + const [did] = await ctx.hydrator.actor.getDids([actor]) + if (!did) throw new InvalidRequestError('Actor not found') + await ctx.bsyncClient.addMuteOperation({ + type: MuteOperation_Type.ADD, + actorDid: requester, + subject: did, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/graph/muteActorList.ts b/packages/bsky/src/api/app/bsky/graph/muteActorList.ts index f2ee8bfeea0..2f9f8c7573f 100644 --- a/packages/bsky/src/api/app/bsky/graph/muteActorList.ts +++ b/packages/bsky/src/api/app/bsky/graph/muteActorList.ts @@ -1,55 +1,18 @@ -import assert from 'node:assert' -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import * as lex from '../../../../lexicon/lexicons' import AppContext from '../../../../context' -import { AtUri } from '@atproto/syntax' import { MuteOperation_Type } from '../../../../proto/bsync_pb' -import { BsyncClient } from '../../../../bsync' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActorList({ auth: ctx.authVerifier.standard, - handler: async ({ req, auth, input }) => { + handler: async ({ auth, input }) => { const { list } = input.body const requester = auth.credentials.iss - - const db = ctx.db.getPrimary() - - const listUri = new AtUri(list) - const collId = lex.ids.AppBskyGraphList - if (listUri.collection !== collId) { - throw new InvalidRequestError(`Invalid collection: expected: ${collId}`) - } - - const muteActorList = async () => { - await ctx.services.graph(db).muteActorList({ - list, - mutedByDid: requester, - }) - } - - const addBsyncMuteOp = async (bsyncClient: BsyncClient) => { - await bsyncClient.addMuteOperation({ - type: MuteOperation_Type.ADD, - actorDid: requester, - subject: list, - }) - } - - if (ctx.cfg.bsyncOnlyMutes) { - assert(ctx.bsyncClient) - await addBsyncMuteOp(ctx.bsyncClient) - } else { - await muteActorList() - if (ctx.bsyncClient) { - try { - await addBsyncMuteOp(ctx.bsyncClient) - } catch (err) { - req.log.warn(err, 'failed to sync mute op to bsync') - } - } - } + await ctx.bsyncClient.addMuteOperation({ + type: MuteOperation_Type.ADD, + actorDid: requester, + subject: list, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts b/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts index e35e00202ef..5462d7a7117 100644 --- a/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts @@ -1,54 +1,21 @@ -import assert from 'node:assert' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { MuteOperation_Type } from '../../../../proto/bsync_pb' -import { BsyncClient } from '../../../../bsync' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActor({ auth: ctx.authVerifier.standard, - handler: async ({ req, auth, input }) => { + handler: async ({ auth, input }) => { const { actor } = input.body const requester = auth.credentials.iss - const db = ctx.db.getPrimary() - - const subjectDid = await ctx.services.actor(db).getActorDid(actor) - if (!subjectDid) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } - if (subjectDid === requester) { - throw new InvalidRequestError('Cannot mute oneself') - } - - const unmuteActor = async () => { - await ctx.services.graph(db).unmuteActor({ - subjectDid, - mutedByDid: requester, - }) - } - - const addBsyncMuteOp = async (bsyncClient: BsyncClient) => { - await bsyncClient.addMuteOperation({ - type: MuteOperation_Type.REMOVE, - actorDid: requester, - subject: subjectDid, - }) - } - - if (ctx.cfg.bsyncOnlyMutes) { - assert(ctx.bsyncClient) - await addBsyncMuteOp(ctx.bsyncClient) - } else { - await unmuteActor() - if (ctx.bsyncClient) { - try { - await addBsyncMuteOp(ctx.bsyncClient) - } catch (err) { - req.log.warn(err, 'failed to sync mute op to bsync') - } - } - } + const [did] = await ctx.hydrator.actor.getDids([actor]) + if (!did) throw new InvalidRequestError('Actor not found') + await ctx.bsyncClient.addMuteOperation({ + type: MuteOperation_Type.REMOVE, + actorDid: requester, + subject: did, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts b/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts index 18d612d7c95..2c80e42187f 100644 --- a/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts +++ b/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts @@ -1,45 +1,18 @@ -import assert from 'node:assert' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { MuteOperation_Type } from '../../../../proto/bsync_pb' -import { BsyncClient } from '../../../../bsync' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActorList({ auth: ctx.authVerifier.standard, - handler: async ({ req, auth, input }) => { + handler: async ({ auth, input }) => { const { list } = input.body const requester = auth.credentials.iss - const db = ctx.db.getPrimary() - - const unmuteActorList = async () => { - await ctx.services.graph(db).unmuteActorList({ - list, - mutedByDid: requester, - }) - } - - const addBsyncMuteOp = async (bsyncClient: BsyncClient) => { - await bsyncClient.addMuteOperation({ - type: MuteOperation_Type.REMOVE, - actorDid: requester, - subject: list, - }) - } - - if (ctx.cfg.bsyncOnlyMutes) { - assert(ctx.bsyncClient) - await addBsyncMuteOp(ctx.bsyncClient) - } else { - await unmuteActorList() - if (ctx.bsyncClient) { - try { - await addBsyncMuteOp(ctx.bsyncClient) - } catch (err) { - req.log.warn(err, 'failed to sync mute op to bsync') - } - } - } + await ctx.bsyncClient.addMuteOperation({ + type: MuteOperation_Type.REMOVE, + actorDid: requester, + subject: list, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts b/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts index 71391457902..afbb5b06780 100644 --- a/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts +++ b/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts @@ -1,43 +1,74 @@ -import { sql } from 'kysely' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { countAll, notSoftDeletedClause } from '../../../../db/util' +import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/getUnreadCount' import AppContext from '../../../../context' +import { + HydrationFnInput, + PresentationFnInput, + SkeletonFnInput, + createPipeline, + noRules, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' export default function (server: Server, ctx: AppContext) { + const getUnreadCount = createPipeline( + skeleton, + hydration, + noRules, + presentation, + ) server.app.bsky.notification.getUnreadCount({ auth: ctx.authVerifier.standard, handler: async ({ auth, params }) => { - const requester = auth.credentials.iss - if (params.seenAt) { - throw new InvalidRequestError('The seenAt parameter is unsupported') - } - - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - const result = await db.db - .selectFrom('notification') - .select(countAll.as('count')) - .innerJoin('actor', 'actor.did', 'notification.did') - .leftJoin('actor_state', 'actor_state.did', 'actor.did') - .innerJoin('record', 'record.uri', 'notification.recordUri') - .where(notSoftDeletedClause(ref('actor'))) - .where(notSoftDeletedClause(ref('record'))) - // Ensure to hit notification_did_sortat_idx, handling case where lastSeenNotifs is null. - .where('notification.did', '=', requester) - .where( - 'notification.sortAt', - '>', - sql`coalesce(${ref('actor_state.lastSeenNotifs')}, ${''})`, - ) - .executeTakeFirst() - - const count = result?.count ?? 0 - + const viewer = auth.credentials.iss + const result = await getUnreadCount({ ...params, viewer }, ctx) return { encoding: 'application/json', - body: { count }, + body: result, } }, }) } + +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input + if (params.seenAt) { + throw new InvalidRequestError('The seenAt parameter is unsupported') + } + const res = await ctx.hydrator.dataplane.getUnreadNotificationCount({ + actorDid: params.viewer, + }) + return { + count: res.count, + } +} + +const hydration = async ( + _input: HydrationFnInput, +) => { + return {} +} + +const presentation = ( + input: PresentationFnInput, +) => { + const { skeleton } = input + return { count: skeleton.count } +} + +type Context = { + hydrator: Hydrator + views: Views +} + +type Params = QueryParams & { + viewer: string +} + +type SkeletonState = { + count: number +} diff --git a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index ce5274a9da7..6f738fb4cca 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -1,16 +1,20 @@ import { InvalidRequestError } from '@atproto/xrpc-server' -import { jsonStringToLex } from '@atproto/lexicon' import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/listNotifications' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { notSoftDeletedClause } from '../../../../db/util' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { getSelfLabels, Labels, LabelService } from '../../../../services/label' -import { createPipeline } from '../../../../pipeline' +import { + createPipeline, + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { Notification } from '../../../../proto/bsky_pb' +import { didFromUri } from '../../../../hydration/util' +import { clearlyBadCursor } from '../../../util' export default function (server: Server, ctx: AppContext) { const listNotifications = createPipeline( @@ -22,17 +26,8 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.listNotifications({ auth: ctx.authVerifier.standard, handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) - const labelService = ctx.services.label(db) const viewer = auth.credentials.iss - - const result = await listNotifications( - { ...params, viewer }, - { db, actorService, graphService, labelService }, - ) - + const result = await listNotifications({ ...params, viewer }, ctx) return { encoding: 'application/json', body: result, @@ -42,141 +37,73 @@ export default function (server: Server, ctx: AppContext) { } const skeleton = async ( - params: Params, - ctx: Context, + input: SkeletonFnInput, ): Promise => { - const { db } = ctx - const { limit, cursor, viewer } = params - const { ref } = db.db.dynamic + const { params, ctx } = input if (params.seenAt) { throw new InvalidRequestError('The seenAt parameter is unsupported') } - if (NotifsKeyset.clearlyBad(cursor)) { - return { params, notifs: [] } + if (clearlyBadCursor(params.cursor)) { + return { notifs: [] } } - let notifBuilder = db.db - .selectFrom('notification as notif') - .where('notif.did', '=', viewer) - .where((clause) => - clause - .where('reasonSubject', 'is', null) - .orWhereExists( - db.db - .selectFrom('record as subject') - .selectAll() - .whereRef('subject.uri', '=', ref('notif.reasonSubject')), - ), - ) - .select([ - 'notif.author as authorDid', - 'notif.recordUri as uri', - 'notif.recordCid as cid', - 'notif.reason as reason', - 'notif.reasonSubject as reasonSubject', - 'notif.sortAt as indexedAt', - ]) - - const keyset = new NotifsKeyset(ref('notif.sortAt'), ref('notif.recordCid')) - notifBuilder = paginate(notifBuilder, { - cursor, - limit, - keyset, - tryIndex: true, - }) - - const actorStateQuery = db.db - .selectFrom('actor_state') - .selectAll() - .where('did', '=', viewer) - - const [notifs, actorState] = await Promise.all([ - notifBuilder.execute(), - actorStateQuery.executeTakeFirst(), + const [res, lastSeenRes] = await Promise.all([ + ctx.hydrator.dataplane.getNotifications({ + actorDid: params.viewer, + cursor: params.cursor, + limit: params.limit, + }), + ctx.hydrator.dataplane.getNotificationSeen({ + actorDid: params.viewer, + }), ]) - + // @NOTE for the first page of results if there's no last-seen time, consider top notification unread + // rather than all notifications. bit of a hack to be more graceful when seen times are out of sync. + let lastSeenDate = lastSeenRes.timestamp?.toDate() + if (!lastSeenDate && !params.cursor) { + lastSeenDate = res.notifications.at(0)?.timestamp?.toDate() + } return { - params, - notifs, - cursor: keyset.packFromResult(notifs), - lastSeenNotifs: actorState?.lastSeenNotifs, + notifs: res.notifications, + cursor: res.cursor || undefined, + lastSeenNotifs: lastSeenDate?.toISOString(), } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService, labelService, db } = ctx - const { params, notifs } = state - const { viewer } = params - const dids = notifs.map((notif) => notif.authorDid) - const uris = notifs.map((notif) => notif.uri) - const [actors, records, labels, bam] = await Promise.all([ - actorService.views.profiles(dids, viewer), - getRecordMap(db, uris), - labelService.getLabelsForUris(uris), - graphService.getBlockAndMuteState(dids.map((did) => [viewer, did])), - ]) - return { ...state, actors, records, labels, bam } +const hydration = async ( + input: HydrationFnInput, +) => { + const { skeleton, params, ctx } = input + return ctx.hydrator.hydrateNotifications(skeleton.notifs, params.viewer) } -const noBlockOrMutes = (state: HydrationState) => { - const { viewer } = state.params - state.notifs = state.notifs.filter( - (item) => - !state.bam.block([viewer, item.authorDid]) && - !state.bam.mute([viewer, item.authorDid]), - ) - return state -} - -const presentation = (state: HydrationState) => { - const { notifs, cursor, actors, records, labels, lastSeenNotifs } = state - const notifications = mapDefined(notifs, (notif) => { - const author = actors[notif.authorDid] - const record = records[notif.uri] - if (!author || !record) return undefined - const recordLabels = labels[notif.uri] ?? [] - const recordSelfLabels = getSelfLabels({ - uri: notif.uri, - cid: notif.cid, - record, - }) - return { - uri: notif.uri, - cid: notif.cid, - author, - reason: notif.reason, - reasonSubject: notif.reasonSubject || undefined, - record, - isRead: lastSeenNotifs ? notif.indexedAt <= lastSeenNotifs : false, - indexedAt: notif.indexedAt, - labels: [...recordLabels, ...recordSelfLabels], - } +const noBlockOrMutes = ( + input: RulesFnInput, +) => { + const { skeleton, hydration, ctx } = input + skeleton.notifs = skeleton.notifs.filter((item) => { + const did = didFromUri(item.uri) + return ( + !ctx.views.viewerBlockExists(did, hydration) && + !ctx.views.viewerMuteExists(did, hydration) + ) }) - return { notifications, cursor, seenAt: lastSeenNotifs } + return skeleton } -const getRecordMap = async ( - db: Database, - uris: string[], -): Promise => { - if (!uris.length) return {} - const { ref } = db.db.dynamic - const recordRows = await db.db - .selectFrom('record') - .select(['uri', 'json']) - .where('uri', 'in', uris) - .where(notSoftDeletedClause(ref('record'))) - .execute() - return recordRows.reduce((acc, { uri, json }) => { - acc[uri] = jsonStringToLex(json) as Record - return acc - }, {} as RecordMap) +const presentation = ( + input: PresentationFnInput, +) => { + const { skeleton, hydration, ctx } = input + const { notifs, lastSeenNotifs, cursor } = skeleton + const notifications = mapDefined(notifs, (notif) => + ctx.views.notification(notif, lastSeenNotifs, hydration), + ) + return { notifications, cursor, seenAt: skeleton.lastSeenNotifs } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService - labelService: LabelService + hydrator: Hydrator + views: Views } type Params = QueryParams & { @@ -184,32 +111,7 @@ type Params = QueryParams & { } type SkeletonState = { - params: Params - notifs: NotifRow[] + notifs: Notification[] lastSeenNotifs?: string cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap - records: RecordMap - labels: Labels -} - -type RecordMap = { [uri: string]: Record } - -type NotifRow = { - authorDid: string - uri: string - cid: string - reason: string - reasonSubject: string | null - indexedAt: string -} - -class NotifsKeyset extends TimeCidKeyset { - labelResult(result: NotifRow) { - return { primary: result.indexedAt, secondary: result.cid } - } -} diff --git a/packages/bsky/src/api/app/bsky/notification/registerPush.ts b/packages/bsky/src/api/app/bsky/notification/registerPush.ts index abce1cd096c..bd4bcec1f2b 100644 --- a/packages/bsky/src/api/app/bsky/notification/registerPush.ts +++ b/packages/bsky/src/api/app/bsky/notification/registerPush.ts @@ -1,15 +1,12 @@ -import assert from 'node:assert' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { Platform } from '../../../../notifications' -import { CourierClient } from '../../../../courier' import { AppPlatform } from '../../../../proto/courier_pb' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.registerPush({ auth: ctx.authVerifier.standard, - handler: async ({ req, auth, input }) => { + handler: async ({ auth, input }) => { const { token, platform, serviceDid, appId } = input.body const did = auth.credentials.iss if (serviceDid !== auth.credentials.aud) { @@ -20,44 +17,17 @@ export default function (server: Server, ctx: AppContext) { 'Unsupported platform: must be "ios", "android", or "web".', ) } - - const db = ctx.db.getPrimary() - - const registerDeviceWithAppview = async () => { - await ctx.services - .actor(db) - .registerPushDeviceToken(did, token, platform as Platform, appId) - } - - const registerDeviceWithCourier = async ( - courierClient: CourierClient, - ) => { - await courierClient.registerDeviceToken({ - did, - token, - platform: - platform === 'ios' - ? AppPlatform.IOS - : platform === 'android' - ? AppPlatform.ANDROID - : AppPlatform.WEB, - appId, - }) - } - - if (ctx.cfg.courierOnlyRegistration) { - assert(ctx.courierClient) - await registerDeviceWithCourier(ctx.courierClient) - } else { - await registerDeviceWithAppview() - if (ctx.courierClient) { - try { - await registerDeviceWithCourier(ctx.courierClient) - } catch (err) { - req.log.warn(err, 'failed to register device token with courier') - } - } - } + await ctx.courierClient.registerDeviceToken({ + did, + token, + platform: + platform === 'ios' + ? AppPlatform.IOS + : platform === 'android' + ? AppPlatform.ANDROID + : AppPlatform.WEB, + appId, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/notification/updateSeen.ts b/packages/bsky/src/api/app/bsky/notification/updateSeen.ts index 4b8b614fbad..4c9e66113d6 100644 --- a/packages/bsky/src/api/app/bsky/notification/updateSeen.ts +++ b/packages/bsky/src/api/app/bsky/notification/updateSeen.ts @@ -1,7 +1,6 @@ +import { Timestamp } from '@bufbuild/protobuf' import { Server } from '../../../../lexicon' -import { InvalidRequestError } from '@atproto/xrpc-server' import AppContext from '../../../../context' -import { excluded } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.updateSeen({ @@ -9,25 +8,10 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ input, auth }) => { const { seenAt } = input.body const viewer = auth.credentials.iss - - let parsed: string - try { - parsed = new Date(seenAt).toISOString() - } catch (_err) { - throw new InvalidRequestError('Invalid date') - } - - const db = ctx.db.getPrimary() - - await db.db - .insertInto('actor_state') - .values({ did: viewer, lastSeenNotifs: parsed }) - .onConflict((oc) => - oc.column('did').doUpdateSet({ - lastSeenNotifs: excluded(db.db, 'lastSeenNotifs'), - }), - ) - .executeTakeFirst() + await ctx.dataplane.updateNotificationSeen({ + actorDid: viewer, + timestamp: Timestamp.fromDate(new Date(seenAt)), + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index c15a5242b0f..a5a3d8a8cef 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -1,107 +1,57 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { countAll } from '../../../../db/util' -import { GenericKeyset, paginate } from '../../../../db/pagination' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { GeneratorView } from '../../../../lexicon/types/app/bsky/feed/defs' +import { parseString } from '../../../../hydration/util' +import { clearlyBadCursor } from '../../../util' // THIS IS A TEMPORARY UNSPECCED ROUTE +// @TODO currently mirrors getSuggestedFeeds and ignores the "query" param. +// In the future may take into consideration popularity via likes w/ its own dataplane endpoint. export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getPopularFeedGenerators({ auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params }) => { - const { limit, cursor, query } = params - const requester = auth.credentials.iss - if (LikeCountKeyset.clearlyBad(cursor)) { + const viewer = auth.credentials.iss + + if (clearlyBadCursor(params.cursor)) { return { encoding: 'application/json', body: { feeds: [] }, } } - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - let inner = db.db - .selectFrom('feed_generator') - .select([ - 'uri', - 'cid', - db.db - .selectFrom('like') - .whereRef('like.subject', '=', ref('feed_generator.uri')) - .select(countAll.as('count')) - .as('likeCount'), - ]) + let uris: string[] + let cursor: string | undefined + const query = params.query?.trim() ?? '' if (query) { - inner = inner.where((qb) => - qb - .where('feed_generator.displayName', 'ilike', `%${query}%`) - .orWhere('feed_generator.description', 'ilike', `%${query}%`), - ) + const res = await ctx.dataplane.searchFeedGenerators({ + query, + limit: params.limit, + }) + uris = res.uris + } else { + const res = await ctx.dataplane.getSuggestedFeeds({ + actorDid: viewer ?? undefined, + limit: params.limit, + cursor: params.cursor, + }) + uris = res.uris + cursor = parseString(res.cursor) } - let builder = db.db.selectFrom(inner.as('feed_gens')).selectAll() - - const keyset = new LikeCountKeyset(ref('likeCount'), ref('cid')) - builder = paginate(builder, { limit, cursor, keyset, direction: 'desc' }) - - const res = await builder.execute() - - const genInfos = await feedService.getFeedGeneratorInfos( - res.map((feed) => feed.uri), - requester, + const hydration = await ctx.hydrator.hydrateFeedGens(uris, viewer) + const feedViews = mapDefined(uris, (uri) => + ctx.views.feedGenerator(uri, hydration), ) - const creators = Object.values(genInfos).map((gen) => gen.creator) - const profiles = await actorService.views.profiles(creators, requester) - - const genViews: GeneratorView[] = [] - for (const row of res) { - const gen = genInfos[row.uri] - if (!gen) continue - const view = feedService.views.formatFeedGeneratorView(gen, profiles) - if (view) { - genViews.push(view) - } - } - return { encoding: 'application/json', body: { - cursor: keyset.packFromResult(res), - feeds: genViews, + feeds: feedViews, + cursor, }, } }, }) } - -type Result = { likeCount: number; cid: string } -type LabeledResult = { primary: number; secondary: string } -export class LikeCountKeyset extends GenericKeyset { - labelResult(result: Result) { - return { - primary: result.likeCount, - secondary: result.cid, - } - } - labeledResultToCursor(labeled: LabeledResult) { - return { - primary: labeled.primary.toString(), - secondary: labeled.secondary, - } - } - cursorToLabeledResult(cursor: { primary: string; secondary: string }) { - const likes = parseInt(cursor.primary, 10) - if (isNaN(likes)) { - throw new InvalidRequestError('Malformed cursor') - } - return { - primary: likes, - secondary: cursor.secondary, - } - } -} diff --git a/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts b/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts index 4fd248e87ff..79df4857945 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts @@ -5,11 +5,12 @@ import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getTaggedSuggestions({ handler: async () => { - const suggestions = await ctx.db - .getReplica() - .db.selectFrom('tagged_suggestion') - .selectAll() - .execute() + const res = await ctx.dataplane.getSuggestedEntities({}) + const suggestions = res.entities.map((entity) => ({ + tag: entity.tag, + subjectType: entity.subjectType, + subject: entity.subject, + })) return { encoding: 'application/json', body: { diff --git a/packages/bsky/src/api/app/bsky/util/feed.ts b/packages/bsky/src/api/app/bsky/util/feed.ts deleted file mode 100644 index 769b2d7e833..00000000000 --- a/packages/bsky/src/api/app/bsky/util/feed.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { TimeCidKeyset } from '../../../../db/pagination' -import { FeedRow } from '../../../../services/feed/types' - -export enum FeedAlgorithm { - ReverseChronological = 'reverse-chronological', -} - -export class FeedKeyset extends TimeCidKeyset { - labelResult(result: FeedRow) { - return { primary: result.sortAt, secondary: result.cid } - } -} - -// For users with sparse feeds, avoid scanning more than one week for a single page -export const getFeedDateThreshold = (from: string | undefined, days = 1) => { - const timelineDateThreshold = from ? new Date(from) : new Date() - timelineDateThreshold.setDate(timelineDateThreshold.getDate() - days) - return timelineDateThreshold.toISOString() -} diff --git a/packages/bsky/src/api/blob-resolver.ts b/packages/bsky/src/api/blob-resolver.ts index c307152c43a..1a2d1ee560d 100644 --- a/packages/bsky/src/api/blob-resolver.ts +++ b/packages/bsky/src/api/blob-resolver.ts @@ -5,11 +5,16 @@ import axios, { AxiosError } from 'axios' import { CID } from 'multiformats/cid' import { ensureValidDid } from '@atproto/syntax' import { forwardStreamErrors, VerifyCidTransform } from '@atproto/common' -import { IdResolver, DidNotFoundError } from '@atproto/identity' +import { DidNotFoundError } from '@atproto/identity' import AppContext from '../context' import { httpLogger as log } from '../logger' import { retryHttp } from '../util/retry' -import { Database } from '../db' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../data-plane' // Resolve and verify blob from its origin host @@ -31,8 +36,7 @@ export const createRouter = (ctx: AppContext): express.Router => { return next(createError(400, 'Invalid cid')) } - const db = ctx.db.getReplica() - const verifiedImage = await resolveBlob(did, cid, db, ctx.idResolver) + const verifiedImage = await resolveBlob(ctx, did, cid) // Send chunked response, destroying stream early (before // closing chunk) if the bytes don't match the expected cid. @@ -76,24 +80,29 @@ export const createRouter = (ctx: AppContext): express.Router => { return router } -export async function resolveBlob( - did: string, - cid: CID, - db: Database, - idResolver: IdResolver, -) { +export async function resolveBlob(ctx: AppContext, did: string, cid: CID) { const cidStr = cid.toString() - const [{ pds }, takedown] = await Promise.all([ - idResolver.did.resolveAtprotoData(did), // @TODO cache did info - db.db - .selectFrom('blob_takedown') - .select('takedownRef') - .where('did', '=', did) - .where('cid', '=', cid.toString()) - .executeTakeFirst(), + const [identity, { takenDown }] = await Promise.all([ + ctx.dataplane.getIdentityByDid({ did }).catch((err) => { + if (isDataplaneError(err, Code.NotFound)) { + return undefined + } + throw err + }), + ctx.dataplane.getBlobTakedown({ did, cid: cid.toString() }), ]) - if (takedown) { + const services = identity && unpackIdentityServices(identity.services) + const pds = + services && + getServiceEndpoint(services, { + id: 'atproto_pds', + type: 'AtprotoPersonalDataServer', + }) + if (!pds) { + throw createError(404, 'Origin not found') + } + if (takenDown) { throw createError(404, 'Blob not found') } diff --git a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts index 9ef66c94c9b..e01be2d6383 100644 --- a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts +++ b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts @@ -1,6 +1,5 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { Actor } from '../../../../db/tables/actor' import { mapDefined } from '@atproto/common' import { INVALID_HANDLE } from '@atproto/syntax' @@ -9,25 +8,16 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.roleOrAdminService, handler: async ({ params }) => { const { dids } = params - const db = ctx.db.getPrimary() - const actorService = ctx.services.actor(db) - const [actors, profiles] = await Promise.all([ - actorService.getActors(dids, true), - actorService.getProfileRecords(dids, true), - ]) - const actorByDid = actors.reduce((acc, cur) => { - return acc.set(cur.did, cur) - }, new Map()) + const actors = await ctx.hydrator.actor.getActors(dids, true) const infos = mapDefined(dids, (did) => { - const info = actorByDid.get(did) + const info = actors.get(did) if (!info) return - const profile = profiles.get(did) return { did, handle: info.handle ?? INVALID_HANDLE, - relatedRecords: profile ? [profile] : undefined, - indexedAt: info.indexedAt, + relatedRecords: info.profile ? [info.profile] : undefined, + indexedAt: (info.sortedAt ?? new Date(0)).toISOString(), } }) diff --git a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts index 8ac237240f9..2ca7bcdc2c9 100644 --- a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts @@ -8,7 +8,7 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.roleOrAdminService, handler: async ({ params }) => { const { did, uri, blob } = params - const modService = ctx.services.moderation(ctx.db.getPrimary()) + let body: OutputSchema | null = null if (blob) { if (!did) { @@ -16,46 +16,48 @@ export default function (server: Server, ctx: AppContext) { 'Must provide a did to request blob state', ) } - const takedown = await modService.getBlobTakedownRef(did, blob) - if (takedown) { - body = { - subject: { - $type: 'com.atproto.admin.defs#repoBlobRef', - did: did, - cid: blob, - }, - takedown, - } + const res = await ctx.dataplane.getBlobTakedown({ + did, + cid: blob, + }) + body = { + subject: { + $type: 'com.atproto.admin.defs#repoBlobRef', + did: did, + cid: blob, + }, + takedown: { + applied: res.takenDown, + ref: res.takedownRef ? 'TAKEDOWN' : undefined, + }, } } else if (uri) { - const [takedown, cidRes] = await Promise.all([ - modService.getRecordTakedownRef(uri), - ctx.db - .getPrimary() - .db.selectFrom('record') - .where('uri', '=', uri) - .select('cid') - .executeTakeFirst(), - ]) - if (cidRes && takedown) { + const res = await ctx.hydrator.getRecord(uri, true) + if (res) { body = { subject: { $type: 'com.atproto.repo.strongRef', uri, - cid: cidRes.cid, + cid: res.cid, + }, + takedown: { + applied: !!res.takedownRef, + ref: res.takedownRef || undefined, }, - takedown, } } } else if (did) { - const takedown = await modService.getRepoTakedownRef(did) - if (takedown) { + const res = (await ctx.hydrator.actor.getActors([did], true)).get(did) + if (res) { body = { subject: { $type: 'com.atproto.admin.defs#repoRef', did: did, }, - takedown, + takedown: { + applied: !!res.takedownRef, + ref: res.takedownRef || undefined, + }, } } } else { diff --git a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts index a7875280137..bb78832aa93 100644 --- a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -1,4 +1,5 @@ -import { AtUri } from '@atproto/syntax' +import { Timestamp } from '@bufbuild/protobuf' +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { @@ -6,8 +7,6 @@ import { isRepoBlobRef, } from '../../../../lexicon/types/com/atproto/admin/defs' import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' -import { CID } from 'multiformats/cid' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateSubjectStatus({ @@ -19,43 +18,49 @@ export default function (server: Server, ctx: AppContext) { 'Must be a full moderator to update subject state', ) } - - const modService = ctx.services.moderation(ctx.db.getPrimary()) - + const now = new Date() const { subject, takedown } = input.body if (takedown) { if (isRepoRef(subject)) { - const did = subject.did if (takedown.applied) { - await modService.takedownRepo({ - takedownRef: takedown.ref ?? new Date().toISOString(), - did, + await ctx.dataplane.takedownActor({ + did: subject.did, + ref: takedown.ref, + seen: Timestamp.fromDate(now), }) } else { - await modService.reverseTakedownRepo({ did }) + await ctx.dataplane.untakedownActor({ + did: subject.did, + seen: Timestamp.fromDate(now), + }) } } else if (isStrongRef(subject)) { - const uri = new AtUri(subject.uri) - const cid = CID.parse(subject.cid) if (takedown.applied) { - await modService.takedownRecord({ - takedownRef: takedown.ref ?? new Date().toISOString(), - uri, - cid, + await ctx.dataplane.takedownRecord({ + recordUri: subject.uri, + ref: takedown.ref, + seen: Timestamp.fromDate(now), }) } else { - await modService.reverseTakedownRecord({ uri }) + await ctx.dataplane.untakedownRecord({ + recordUri: subject.uri, + seen: Timestamp.fromDate(now), + }) } } else if (isRepoBlobRef(subject)) { - const { did, cid } = subject if (takedown.applied) { - await modService.takedownBlob({ - takedownRef: takedown.ref ?? new Date().toISOString(), - did, - cid, + await ctx.dataplane.takedownBlob({ + did: subject.did, + cid: subject.cid, + ref: takedown.ref, + seen: Timestamp.fromDate(now), }) } else { - await modService.reverseTakedownBlob({ did, cid }) + await ctx.dataplane.untakedownBlob({ + did: subject.did, + cid: subject.cid, + seen: Timestamp.fromDate(now), + }) } } else { throw new InvalidRequestError('Invalid subject') diff --git a/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts b/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts index 30c1d7f8a6f..6cb524c6ec2 100644 --- a/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts +++ b/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts @@ -7,12 +7,9 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.identity.resolveHandle(async ({ req, params }) => { const handle = ident.normalizeHandle(params.handle || req.hostname) - const db = ctx.db.getReplica() - let did: string | undefined - const user = await ctx.services.actor(db).getActor(handle, true) - if (user) { - did = user.did - } else { + let [did] = await ctx.hydrator.actor.getDids([handle]) + + if (!did) { const publicHostname = ctx.cfg.publicUrl ? new URL(ctx.cfg.publicUrl).hostname : null diff --git a/packages/bsky/src/api/com/atproto/repo/getRecord.ts b/packages/bsky/src/api/com/atproto/repo/getRecord.ts index c42c1fd6b4c..95f2c5eda81 100644 --- a/packages/bsky/src/api/com/atproto/repo/getRecord.ts +++ b/packages/bsky/src/api/com/atproto/repo/getRecord.ts @@ -2,37 +2,28 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { jsonStringToLex } from '@atproto/lexicon' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.getRecord(async ({ params }) => { const { repo, collection, rkey, cid } = params - const db = ctx.db.getReplica() - const did = await ctx.services.actor(db).getActorDid(repo) + const [did] = await ctx.hydrator.actor.getDids([repo]) if (!did) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } - const uri = AtUri.make(did, collection, rkey) + const uri = AtUri.make(did, collection, rkey).toString() + const result = await ctx.hydrator.getRecord(uri, true) - let builder = db.db - .selectFrom('record') - .selectAll() - .where('uri', '=', uri.toString()) - if (cid) { - builder = builder.where('cid', '=', cid) - } - - const record = await builder.executeTakeFirst() - if (!record) { + if (!result || (cid && result.cid !== cid)) { throw new InvalidRequestError(`Could not locate record: ${uri}`) } + return { - encoding: 'application/json', + encoding: 'application/json' as const, body: { - uri: record.uri, - cid: record.cid, - value: jsonStringToLex(record.json) as Record, + uri: uri, + cid: result.cid, + value: result.record, }, } }) diff --git a/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts b/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts index 8a6cacc2fbd..044a8d4dfd4 100644 --- a/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts +++ b/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts @@ -1,30 +1,9 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' -export default function (server: Server, ctx: AppContext) { - server.com.atproto.temp.fetchLabels(async ({ params }) => { - const { limit } = params - const db = ctx.db.getReplica() - const since = - params.since !== undefined ? new Date(params.since).toISOString() : '' - const labelRes = await db.db - .selectFrom('label') - .selectAll() - .orderBy('label.cts', 'asc') - .where('cts', '>', since) - .limit(limit) - .execute() - - const labels = labelRes.map((l) => ({ - ...l, - cid: l.cid === '' ? undefined : l.cid, - })) - - return { - encoding: 'application/json', - body: { - labels, - }, - } +export default function (server: Server, _ctx: AppContext) { + server.com.atproto.temp.fetchLabels(async (_reqCtx) => { + throw new InvalidRequestError('not implemented on dataplane') }) } diff --git a/packages/bsky/src/api/health.ts b/packages/bsky/src/api/health.ts index b8ebadd4b71..20190cd030c 100644 --- a/packages/bsky/src/api/health.ts +++ b/packages/bsky/src/api/health.ts @@ -1,5 +1,4 @@ import express from 'express' -import { sql } from 'kysely' import AppContext from '../context' export const createRouter = (ctx: AppContext): express.Router => { @@ -21,9 +20,8 @@ export const createRouter = (ctx: AppContext): express.Router => { router.get('/xrpc/_health', async function (req, res) { const { version } = ctx.cfg - const db = ctx.db.getPrimary() try { - await sql`select 1`.execute(db.db) + await ctx.dataplane.ping({}) } catch (err) { req.log.error(err, 'failed health check') return res.status(503).send({ version, error: 'Service Unavailable' }) diff --git a/packages/bsky/src/api/util.ts b/packages/bsky/src/api/util.ts index ef7e51bc95e..2fe54a8a7be 100644 --- a/packages/bsky/src/api/util.ts +++ b/packages/bsky/src/api/util.ts @@ -5,3 +5,8 @@ export const setRepoRev = (res: express.Response, rev: string | null) => { res.setHeader('Atproto-Repo-Rev', rev) } } + +export const clearlyBadCursor = (cursor?: string) => { + // hallmark of v1 cursor, highly unlikely in v2 cursors based on time or rkeys + return !!cursor?.includes('::') +} diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 5a2bf753072..7798efa99b2 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -2,9 +2,16 @@ import { AuthRequiredError, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' import * as ui8 from 'uint8arrays' import express from 'express' +import { + Code, + DataPlaneClient, + getKeyAsDidKey, + isDataplaneError, + unpackIdentityKeys, +} from './data-plane' +import { GetIdentityByDidResponse } from './proto/bsky_pb' type ReqCtx = { req: express.Request @@ -35,8 +42,6 @@ type RoleOutput = { credentials: { type: 'role' admin: boolean - moderator: boolean - triage: boolean } } @@ -51,29 +56,35 @@ type AdminServiceOutput = { export type AuthVerifierOpts = { ownDid: string adminDid: string - adminPass: string - moderatorPass: string - triagePass: string + adminPasses: string[] } export class AuthVerifier { - private _adminPass: string - private _moderatorPass: string - private _triagePass: string public ownDid: string public adminDid: string + private adminPasses: Set - constructor(public idResolver: IdResolver, opts: AuthVerifierOpts) { - this._adminPass = opts.adminPass - this._moderatorPass = opts.moderatorPass - this._triagePass = opts.triagePass + constructor(public dataplane: DataPlaneClient, opts: AuthVerifierOpts) { this.ownDid = opts.ownDid this.adminDid = opts.adminDid + this.adminPasses = new Set(opts.adminPasses) } // verifiers (arrow fns to preserve scope) standard = async (ctx: ReqCtx): Promise => { + // @TODO remove! basic auth + did supported just for testing. + if (isBasicToken(ctx.req)) { + const aud = this.ownDid + const iss = ctx.req.headers['appview-as-did'] + if (typeof iss !== 'string' || !iss.startsWith('did:')) { + throw new AuthRequiredError('bad issuer') + } + if (!this.parseRoleCreds(ctx.req).admin) { + throw new AuthRequiredError('bad credentials') + } + return { credentials: { type: 'standard', iss, aud } } + } const { iss, aud } = await this.verifyServiceJwt(ctx, { aud: this.ownDid, iss: null, @@ -84,7 +95,7 @@ export class AuthVerifier { standardOptional = async ( ctx: ReqCtx, ): Promise => { - if (isBearerToken(ctx.req)) { + if (isBearerToken(ctx.req) || isBasicToken(ctx.req)) { return this.standard(ctx) } return this.nullCreds() @@ -173,16 +184,10 @@ export class AuthVerifier { return { status: Missing, admin: false, moderator: false, triage: false } } const { username, password } = parsed - if (username === 'admin' && password === this._adminPass) { - return { status: Valid, admin: true, moderator: true, triage: true } - } - if (username === 'admin' && password === this._moderatorPass) { - return { status: Valid, admin: false, moderator: true, triage: true } + if (username === 'admin' && this.adminPasses.has(password)) { + return { status: Valid, admin: true } } - if (username === 'admin' && password === this._triagePass) { - return { status: Valid, admin: false, moderator: false, triage: true } - } - return { status: Invalid, admin: false, moderator: false, triage: false } + return { status: Invalid, admin: false } } async verifyServiceJwt( @@ -191,12 +196,26 @@ export class AuthVerifier { ) { const getSigningKey = async ( did: string, - forceRefresh: boolean, + _forceRefresh: boolean, // @TODO consider propagating to dataplane ): Promise => { if (opts.iss !== null && !opts.iss.includes(did)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - return this.idResolver.did.resolveAtprotoKey(did, forceRefresh) + let identity: GetIdentityByDidResponse + try { + identity = await this.dataplane.getIdentityByDid({ did }) + } catch (err) { + if (isDataplaneError(err, Code.NotFound)) { + throw new AuthRequiredError('identity unknown') + } + throw err + } + const keys = unpackIdentityKeys(identity.keys) + const didKey = getKeyAsDidKey(keys, { id: 'atproto' }) + if (!didKey) { + throw new AuthRequiredError('missing or bad key') + } + return didKey } const jwtStr = bearerTokenFromReq(reqCtx.req) @@ -222,10 +241,10 @@ export class AuthVerifier { const viewer = creds.credentials.type === 'standard' ? creds.credentials.iss : null const canViewTakedowns = - (creds.credentials.type === 'role' && creds.credentials.triage) || + (creds.credentials.type === 'role' && creds.credentials.admin) || creds.credentials.type === 'admin_service' const canPerformTakedown = - (creds.credentials.type === 'role' && creds.credentials.moderator) || + (creds.credentials.type === 'role' && creds.credentials.admin) || creds.credentials.type === 'admin_service' return { viewer, @@ -245,6 +264,10 @@ const isBearerToken = (req: express.Request): boolean => { return req.headers.authorization?.startsWith(BEARER) ?? false } +const isBasicToken = (req: express.Request): boolean => { + return req.headers.authorization?.startsWith(BASIC) ?? false +} + const bearerTokenFromReq = (req: express.Request) => { const header = req.headers.authorization || '' if (!header.startsWith(BEARER)) return null diff --git a/packages/bsky/src/auto-moderator/hive.ts b/packages/bsky/src/auto-moderator/hive.ts deleted file mode 100644 index 51d67c1c783..00000000000 --- a/packages/bsky/src/auto-moderator/hive.ts +++ /dev/null @@ -1,187 +0,0 @@ -import axios from 'axios' -import FormData from 'form-data' -import { CID } from 'multiformats/cid' -import { IdResolver } from '@atproto/identity' -import { PrimaryDatabase } from '../db' -import { retryHttp } from '../util/retry' -import { resolveBlob } from '../api/blob-resolver' -import { labelerLogger as log } from '../logger' - -const HIVE_ENDPOINT = 'https://api.thehive.ai/api/v2/task/sync' - -export interface ImgLabeler { - labelImg(did: string, cid: CID): Promise -} - -export class HiveLabeler implements ImgLabeler { - constructor( - public hiveApiKey: string, - protected ctx: { - db: PrimaryDatabase - idResolver: IdResolver - }, - ) {} - - async labelImg(did: string, cid: CID): Promise { - const hiveRes = await retryHttp(async () => { - try { - return await this.makeHiveReq(did, cid) - } catch (err) { - log.warn({ err, did, cid: cid.toString() }, 'hive request failed') - throw err - } - }) - log.info({ hiveRes, did, cid: cid.toString() }, 'hive response') - const classes = respToClasses(hiveRes) - return summarizeLabels(classes) - } - - async makeHiveReq(did: string, cid: CID): Promise { - const { stream } = await resolveBlob( - did, - cid, - this.ctx.db, - this.ctx.idResolver, - ) - const form = new FormData() - form.append('media', stream) - const { data } = await axios.post(HIVE_ENDPOINT, form, { - headers: { - 'Content-Type': 'multipart/form-data', - authorization: `token ${this.hiveApiKey}`, - accept: 'application/json', - }, - }) - return data - } -} - -export const respToClasses = (res: HiveResp): HiveRespClass[] => { - const classes: HiveRespClass[] = [] - for (const status of res.status) { - for (const out of status.response.output) { - for (const cls of out.classes) { - classes.push(cls) - } - } - } - return classes -} - -// Matches only one (or none) of: porn, sexual, nudity -// -// porn: sexual and nudity. including both explicit activity or full-frontal and suggestive/intent -// sexual: sexually suggestive, not explicit; may include some forms of nudity -// nudity: non-sexual nudity (eg, artistic, possibly some photographic) -// -// hive docs/definitions: https://docs.thehive.ai/docs/sexual-content -export const sexualLabels = (classes: HiveRespClass[]): string[] => { - const scores = {} - - for (const cls of classes) { - scores[cls.class] = cls.score - } - - // first check if porn... - for (const pornClass of [ - 'yes_sexual_activity', - 'animal_genitalia_and_human', - 'yes_realistic_nsfw', - ]) { - if (scores[pornClass] >= 0.9) { - return ['porn'] - } - } - if (scores['general_nsfw'] >= 0.9) { - // special case for some anime examples - if (scores['animated_animal_genitalia'] >= 0.5) { - return ['porn'] - } - // special case for some pornographic/explicit classic drawings - if (scores['yes_undressed'] >= 0.9 && scores['yes_sexual_activity'] > 0.9) { - return ['porn'] - } - } - - // then check for sexual suggestive (which may include nudity)... - for (const sexualClass of ['yes_sexual_intent', 'yes_sex_toy']) { - if (scores[sexualClass] >= 0.9) { - return ['sexual'] - } - } - if (scores['yes_undressed'] >= 0.9) { - // special case for bondage examples - if (scores['yes_sex_toy'] > 0.75) { - return ['sexual'] - } - } - - // then non-sexual nudity... - for (const nudityClass of [ - 'yes_male_nudity', - 'yes_female_nudity', - 'yes_undressed', - ]) { - if (scores[nudityClass] >= 0.9) { - return ['nudity'] - } - } - - // then finally flag remaining "underwear" images in to sexually suggestive - // (after non-sexual content already labeled above) - for (const nudityClass of ['yes_male_underwear', 'yes_female_underwear']) { - if (scores[nudityClass] >= 0.9) { - // TODO: retaining 'underwear' label for a short time to help understand - // the impact of labeling all "underwear" as "sexual". This *will* be - // pulling in somewhat non-sexual content in to "sexual" label. - return ['sexual'] - } - } - - return [] -} - -// gore and violence: https://docs.thehive.ai/docs/class-descriptions-violence-gore -const labelForClass = { - very_bloody: 'gore', - human_corpse: 'corpse', - hanging: 'corpse', -} -const labelForClassLessSensitive = { - yes_self_harm: 'self-harm', -} - -export const summarizeLabels = (classes: HiveRespClass[]): string[] => { - const labels: string[] = sexualLabels(classes) - for (const cls of classes) { - if (labelForClass[cls.class] && cls.score >= 0.9) { - labels.push(labelForClass[cls.class]) - } - } - for (const cls of classes) { - if (labelForClassLessSensitive[cls.class] && cls.score >= 0.96) { - labels.push(labelForClassLessSensitive[cls.class]) - } - } - return labels -} - -type HiveResp = { - status: HiveRespStatus[] -} - -type HiveRespStatus = { - response: { - output: HiveRespOutput[] - } -} - -type HiveRespOutput = { - time: number - classes: HiveRespClass[] -} - -type HiveRespClass = { - class: string - score: number -} diff --git a/packages/bsky/src/auto-moderator/index.ts b/packages/bsky/src/auto-moderator/index.ts deleted file mode 100644 index 7883a26d9e4..00000000000 --- a/packages/bsky/src/auto-moderator/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { AtUri } from '@atproto/syntax' -import { AtpAgent } from '@atproto/api' -import { dedupe, getFieldsFromRecord } from './util' -import { labelerLogger as log } from '../logger' -import { PrimaryDatabase } from '../db' -import { IdResolver } from '@atproto/identity' -import { BackgroundQueue } from '../background' -import { IndexerConfig } from '../indexer/config' -import { buildBasicAuth } from '../auth-verifier' -import { CID } from 'multiformats/cid' -import { HiveLabeler, ImgLabeler } from './hive' -import { KeywordLabeler, TextLabeler } from './keyword' -import { ids } from '../lexicon/lexicons' - -export class AutoModerator { - public pushAgent: AtpAgent - public imgLabeler?: ImgLabeler - public textLabeler?: TextLabeler - - constructor( - public ctx: { - db: PrimaryDatabase - idResolver: IdResolver - cfg: IndexerConfig - backgroundQueue: BackgroundQueue - }, - ) { - const { hiveApiKey } = ctx.cfg - this.imgLabeler = hiveApiKey ? new HiveLabeler(hiveApiKey, ctx) : undefined - this.textLabeler = new KeywordLabeler(ctx.cfg.labelerKeywords) - - const url = new URL(ctx.cfg.moderationPushUrl) - this.pushAgent = new AtpAgent({ service: url.origin }) - this.pushAgent.api.setHeader( - 'authorization', - buildBasicAuth(url.username, url.password), - ) - } - - processRecord(uri: AtUri, cid: CID, obj: unknown) { - this.ctx.backgroundQueue.add(async () => { - const { text, imgs } = getFieldsFromRecord(obj, uri) - await this.labelRecord(uri, cid, text, imgs).catch((err) => { - log.error( - { err, uri: uri.toString(), record: obj }, - 'failed to label record', - ) - }) - }) - } - - processHandle(_handle: string, _did: string) { - // no-op since this functionality moved to auto-mod service - } - - async labelRecord(uri: AtUri, recordCid: CID, text: string[], imgs: CID[]) { - if (uri.collection !== ids.AppBskyFeedPost) { - // @TODO label profiles - return - } - const allLabels = await Promise.all([ - this.textLabeler?.labelText(text.join(' ')), - ...imgs.map((cid) => this.imgLabeler?.labelImg(uri.host, cid)), - ]) - const labels = dedupe(allLabels.flat()) - await this.pushLabels(uri, recordCid, labels) - } - - async pushLabels(uri: AtUri, cid: CID, labels: string[]): Promise { - if (labels.length < 1) return - - await this.pushAgent.com.atproto.admin.emitModerationEvent({ - event: { - $type: 'com.atproto.admin.defs#modEventLabel', - comment: '[AutoModerator]: Applying labels', - createLabelVals: labels, - negateLabelVals: [], - }, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: uri.toString(), - cid: cid.toString(), - }, - createdBy: this.ctx.cfg.serverDid, - }) - } - - async processAll() { - await this.ctx.backgroundQueue.processAll() - } -} diff --git a/packages/bsky/src/auto-moderator/keyword.ts b/packages/bsky/src/auto-moderator/keyword.ts deleted file mode 100644 index 6bc504aa142..00000000000 --- a/packages/bsky/src/auto-moderator/keyword.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface TextLabeler { - labelText(text: string): Promise -} - -export class KeywordLabeler implements TextLabeler { - constructor(public keywords: Record) {} - - async labelText(text: string): Promise { - return keywordLabeling(this.keywords, text) - } -} - -export const keywordLabeling = ( - keywords: Record, - text: string, -): string[] => { - const lowerText = text.toLowerCase() - const labels: string[] = [] - for (const word of Object.keys(keywords)) { - if (lowerText.includes(word)) { - labels.push(keywords[word]) - } - } - return labels -} diff --git a/packages/bsky/src/auto-moderator/util.ts b/packages/bsky/src/auto-moderator/util.ts deleted file mode 100644 index ab1467a07f2..00000000000 --- a/packages/bsky/src/auto-moderator/util.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { CID } from 'multiformats/cid' -import { AtUri } from '@atproto/syntax' -import * as lex from '../lexicon/lexicons' -import { - isRecord as isPost, - Record as PostRecord, -} from '../lexicon/types/app/bsky/feed/post' -import { - isRecord as isProfile, - Record as ProfileRecord, -} from '../lexicon/types/app/bsky/actor/profile' -import { - isRecord as isList, - Record as ListRecord, -} from '../lexicon/types/app/bsky/graph/list' -import { - isRecord as isGenerator, - Record as GeneratorRecord, -} from '../lexicon/types/app/bsky/feed/generator' -import { isMain as isEmbedImage } from '../lexicon/types/app/bsky/embed/images' -import { isMain as isEmbedExternal } from '../lexicon/types/app/bsky/embed/external' -import { isMain as isEmbedRecordWithMedia } from '../lexicon/types/app/bsky/embed/recordWithMedia' - -type RecordFields = { - text: string[] - imgs: CID[] -} - -export const getFieldsFromRecord = ( - record: unknown, - uri: AtUri, -): RecordFields => { - if (isPost(record)) { - return getFieldsFromPost(record) - } else if (isProfile(record)) { - return getFieldsFromProfile(record) - } else if (isList(record)) { - return getFieldsFromList(record) - } else if (isGenerator(record)) { - return getFieldsFromGenerator(record, uri) - } else { - return { text: [], imgs: [] } - } -} - -export const getFieldsFromPost = (record: PostRecord): RecordFields => { - const text: string[] = [] - const imgs: CID[] = [] - text.push(record.text) - const embeds = separateEmbeds(record.embed) - for (const embed of embeds) { - if (isEmbedImage(embed)) { - for (const img of embed.images) { - imgs.push(img.image.ref) - text.push(img.alt) - } - } else if (isEmbedExternal(embed)) { - if (embed.external.thumb) { - imgs.push(embed.external.thumb.ref) - } - text.push(embed.external.title) - text.push(embed.external.description) - } - } - return { text, imgs } -} - -export const getFieldsFromProfile = (record: ProfileRecord): RecordFields => { - const text: string[] = [] - const imgs: CID[] = [] - if (record.displayName) { - text.push(record.displayName) - } - if (record.description) { - text.push(record.description) - } - if (record.avatar) { - imgs.push(record.avatar.ref) - } - if (record.banner) { - imgs.push(record.banner.ref) - } - return { text, imgs } -} - -export const getFieldsFromList = (record: ListRecord): RecordFields => { - const text: string[] = [] - const imgs: CID[] = [] - if (record.name) { - text.push(record.name) - } - if (record.description) { - text.push(record.description) - } - if (record.avatar) { - imgs.push(record.avatar.ref) - } - return { text, imgs } -} - -export const getFieldsFromGenerator = ( - record: GeneratorRecord, - uri: AtUri, -): RecordFields => { - const text: string[] = [] - const imgs: CID[] = [] - text.push(uri.rkey) - if (record.displayName) { - text.push(record.displayName) - } - if (record.description) { - text.push(record.description) - } - if (record.avatar) { - imgs.push(record.avatar.ref) - } - return { text, imgs } -} - -export const dedupe = (strs: (string | undefined)[]): string[] => { - const set = new Set() - for (const str of strs) { - if (str !== undefined) { - set.add(str) - } - } - return [...set] -} - -const separateEmbeds = (embed: PostRecord['embed']) => { - if (!embed) { - return [] - } - if (isEmbedRecordWithMedia(embed)) { - return [{ $type: lex.ids.AppBskyEmbedRecord, ...embed.record }, embed.media] - } - return [embed] -} diff --git a/packages/bsky/src/config.ts b/packages/bsky/src/config.ts index 1d88abb588a..6f9c96776f4 100644 --- a/packages/bsky/src/config.ts +++ b/packages/bsky/src/config.ts @@ -1,53 +1,35 @@ -import assert from 'assert' -import { - DAY, - HOUR, - MINUTE, - SECOND, - parseIntWithFallback, -} from '@atproto/common' +import assert from 'node:assert' export interface ServerConfigValues { - version: string + // service + version?: string debugMode?: boolean port?: number publicUrl?: string serverDid: string - feedGenDid?: string - dbPrimaryPostgresUrl: string - dbReplicaPostgresUrls?: string[] - dbReplicaTags?: Record // E.g. { timeline: [0], thread: [1] } - dbPostgresSchema?: string - redisHost?: string // either set redis host, or both sentinel name and hosts - redisSentinelName?: string - redisSentinelHosts?: string[] - redisPassword?: string - didPlcUrl: string - didCacheStaleTTL: number - didCacheMaxTTL: number - labelCacheStaleTTL: number - labelCacheMaxTTL: number - handleResolveNameservers?: string[] - imgUriEndpoint?: string - blobCacheLocation?: string - searchEndpoint?: string - bsyncUrl?: string + // external services + dataplaneUrls: string[] + dataplaneHttpVersion?: '1.1' | '2' + dataplaneIgnoreBadTls?: boolean + bsyncUrl: string bsyncApiKey?: string bsyncHttpVersion?: '1.1' | '2' bsyncIgnoreBadTls?: boolean - bsyncOnlyMutes?: boolean - courierUrl?: string + courierUrl: string courierApiKey?: string courierHttpVersion?: '1.1' | '2' courierIgnoreBadTls?: boolean - courierOnlyRegistration?: boolean - adminPassword: string - moderatorPassword: string - triagePassword: string + searchUrl?: string + cdnUrl?: string + // identity + didPlcUrl: string + handleResolveNameservers?: string[] + // moderation and administration modServiceDid: string - rateLimitsEnabled: boolean - rateLimitBypassKey?: string - rateLimitBypassIps?: string[] + adminPasswords: string[] + labelsFromIssuerDids?: string[] + // misc/dev + blobCacheLocation?: string } export class ServerConfig { @@ -55,149 +37,77 @@ export class ServerConfig { constructor(private cfg: ServerConfigValues) {} static readEnv(overrides?: Partial) { - const version = process.env.BSKY_VERSION || '0.0.0' + const version = process.env.BSKY_VERSION || undefined const debugMode = process.env.NODE_ENV !== 'production' - const publicUrl = process.env.PUBLIC_URL || undefined - const serverDid = process.env.SERVER_DID || 'did:example:test' - const feedGenDid = process.env.FEED_GEN_DID - const envPort = parseInt(process.env.PORT || '', 10) + const publicUrl = process.env.BSKY_PUBLIC_URL || undefined + const serverDid = process.env.BSKY_SERVER_DID || 'did:example:test' + const envPort = parseInt(process.env.BSKY_PORT || '', 10) const port = isNaN(envPort) ? 2584 : envPort - const redisHost = - overrides?.redisHost || process.env.REDIS_HOST || undefined - const redisSentinelName = - overrides?.redisSentinelName || - process.env.REDIS_SENTINEL_NAME || + const didPlcUrl = process.env.BSKY_DID_PLC_URL || 'http://localhost:2582' + const handleResolveNameservers = process.env.BSKY_HANDLE_RESOLVE_NAMESERVERS + ? process.env.BSKY_HANDLE_RESOLVE_NAMESERVERS.split(',') + : [] + const cdnUrl = process.env.BSKY_CDN_URL || process.env.BSKY_IMG_URI_ENDPOINT + const blobCacheLocation = process.env.BSKY_BLOB_CACHE_LOC + const searchUrl = + process.env.BSKY_SEARCH_URL || + process.env.BSKY_SEARCH_ENDPOINT || undefined - const redisSentinelHosts = - overrides?.redisSentinelHosts || - (process.env.REDIS_SENTINEL_HOSTS - ? process.env.REDIS_SENTINEL_HOSTS.split(',') - : []) - const redisPassword = - overrides?.redisPassword || process.env.REDIS_PASSWORD || undefined - const didPlcUrl = process.env.DID_PLC_URL || 'http://localhost:2582' - const didCacheStaleTTL = parseIntWithFallback( - process.env.DID_CACHE_STALE_TTL, - HOUR, - ) - const didCacheMaxTTL = parseIntWithFallback( - process.env.DID_CACHE_MAX_TTL, - DAY, - ) - const labelCacheStaleTTL = parseIntWithFallback( - process.env.LABEL_CACHE_STALE_TTL, - 30 * SECOND, - ) - const labelCacheMaxTTL = parseIntWithFallback( - process.env.LABEL_CACHE_MAX_TTL, - MINUTE, - ) - const handleResolveNameservers = process.env.HANDLE_RESOLVE_NAMESERVERS - ? process.env.HANDLE_RESOLVE_NAMESERVERS.split(',') + let dataplaneUrls = overrides?.dataplaneUrls + dataplaneUrls ??= process.env.BSKY_DATAPLANE_URLS + ? process.env.BSKY_DATAPLANE_URLS.split(',') + : [] + const dataplaneHttpVersion = process.env.BSKY_DATAPLANE_HTTP_VERSION || '2' + const dataplaneIgnoreBadTls = + process.env.BSKY_DATAPLANE_IGNORE_BAD_TLS === 'true' + const labelsFromIssuerDids = process.env.BSKY_LABELS_FROM_ISSUER_DIDS + ? process.env.BSKY_LABELS_FROM_ISSUER_DIDS.split(',') : [] - const imgUriEndpoint = process.env.IMG_URI_ENDPOINT - const blobCacheLocation = process.env.BLOB_CACHE_LOC - const searchEndpoint = process.env.SEARCH_ENDPOINT const bsyncUrl = process.env.BSKY_BSYNC_URL || undefined + assert(bsyncUrl) const bsyncApiKey = process.env.BSKY_BSYNC_API_KEY || undefined const bsyncHttpVersion = process.env.BSKY_BSYNC_HTTP_VERSION || '2' const bsyncIgnoreBadTls = process.env.BSKY_BSYNC_IGNORE_BAD_TLS === 'true' - const bsyncOnlyMutes = process.env.BSKY_BSYNC_ONLY_MUTES === 'true' - assert(!bsyncOnlyMutes || bsyncUrl, 'bsync-only mutes requires a bsync url') assert(bsyncHttpVersion === '1.1' || bsyncHttpVersion === '2') const courierUrl = process.env.BSKY_COURIER_URL || undefined + assert(courierUrl) const courierApiKey = process.env.BSKY_COURIER_API_KEY || undefined const courierHttpVersion = process.env.BSKY_COURIER_HTTP_VERSION || '2' const courierIgnoreBadTls = process.env.BSKY_COURIER_IGNORE_BAD_TLS === 'true' - const courierOnlyRegistration = - process.env.BSKY_COURIER_ONLY_REGISTRATION === 'true' - assert( - !courierOnlyRegistration || courierUrl, - 'courier-only registration requires a courier url', - ) assert(courierHttpVersion === '1.1' || courierHttpVersion === '2') - const dbPrimaryPostgresUrl = - overrides?.dbPrimaryPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL - let dbReplicaPostgresUrls = overrides?.dbReplicaPostgresUrls - if (!dbReplicaPostgresUrls && process.env.DB_REPLICA_POSTGRES_URLS) { - dbReplicaPostgresUrls = process.env.DB_REPLICA_POSTGRES_URLS.split(',') - } - const dbReplicaTags = overrides?.dbReplicaTags ?? { - '*': getTagIdxs(process.env.DB_REPLICA_TAGS_ANY), // e.g. DB_REPLICA_TAGS_ANY=0,1 - timeline: getTagIdxs(process.env.DB_REPLICA_TAGS_TIMELINE), - feed: getTagIdxs(process.env.DB_REPLICA_TAGS_FEED), - search: getTagIdxs(process.env.DB_REPLICA_TAGS_SEARCH), - thread: getTagIdxs(process.env.DB_REPLICA_TAGS_THREAD), - } - assert( - Object.values(dbReplicaTags) - .flat() - .every((idx) => idx < (dbReplicaPostgresUrls?.length ?? 0)), - 'out of range index in replica tags', + const adminPasswords = envList( + process.env.BSKY_ADMIN_PASSWORDS || process.env.BSKY_ADMIN_PASSWORD, ) - const dbPostgresSchema = process.env.DB_POSTGRES_SCHEMA - assert(dbPrimaryPostgresUrl) - const adminPassword = process.env.ADMIN_PASSWORD || undefined - assert(adminPassword) - const moderatorPassword = process.env.MODERATOR_PASSWORD || undefined - assert(moderatorPassword) - const triagePassword = process.env.TRIAGE_PASSWORD || undefined - assert(triagePassword) - const modServiceDid = - overrides?.modServiceDid || - process.env.MODERATION_SERVICE_DID || - undefined + const modServiceDid = process.env.MOD_SERVICE_DID assert(modServiceDid) - const rateLimitsEnabled = process.env.RATE_LIMITS_ENABLED === 'true' - const rateLimitBypassKey = process.env.RATE_LIMIT_BYPASS_KEY - const rateLimitBypassIps = process.env.RATE_LIMIT_BYPASS_IPS - ? process.env.RATE_LIMIT_BYPASS_IPS.split(',').map((ipOrCidr) => - ipOrCidr.split('/')[0]?.trim(), - ) - : undefined - + assert(dataplaneUrls.length) + assert(dataplaneHttpVersion === '1.1' || dataplaneHttpVersion === '2') return new ServerConfig({ version, debugMode, port, publicUrl, serverDid, - feedGenDid, - dbPrimaryPostgresUrl, - dbReplicaPostgresUrls, - dbReplicaTags, - dbPostgresSchema, - redisHost, - redisSentinelName, - redisSentinelHosts, - redisPassword, + dataplaneUrls, + dataplaneHttpVersion, + dataplaneIgnoreBadTls, + searchUrl, didPlcUrl, - didCacheStaleTTL, - didCacheMaxTTL, - labelCacheStaleTTL, - labelCacheMaxTTL, + labelsFromIssuerDids, handleResolveNameservers, - imgUriEndpoint, + cdnUrl, blobCacheLocation, - searchEndpoint, bsyncUrl, bsyncApiKey, bsyncHttpVersion, bsyncIgnoreBadTls, - bsyncOnlyMutes, courierUrl, courierApiKey, courierHttpVersion, courierIgnoreBadTls, - courierOnlyRegistration, - adminPassword, - moderatorPassword, - triagePassword, + adminPasswords, modServiceDid, - rateLimitsEnabled, - rateLimitBypassKey, - rateLimitBypassIps, ...stripUndefineds(overrides ?? {}), }) } @@ -235,76 +145,16 @@ export class ServerConfig { return this.cfg.serverDid } - get feedGenDid() { - return this.cfg.feedGenDid - } - - get dbPrimaryPostgresUrl() { - return this.cfg.dbPrimaryPostgresUrl - } - - get dbReplicaPostgresUrl() { - return this.cfg.dbReplicaPostgresUrls - } - - get dbReplicaTags() { - return this.cfg.dbReplicaTags - } - - get dbPostgresSchema() { - return this.cfg.dbPostgresSchema + get dataplaneUrls() { + return this.cfg.dataplaneUrls } - get redisHost() { - return this.cfg.redisHost + get dataplaneHttpVersion() { + return this.cfg.dataplaneHttpVersion } - get redisSentinelName() { - return this.cfg.redisSentinelName - } - - get redisSentinelHosts() { - return this.cfg.redisSentinelHosts - } - - get redisPassword() { - return this.cfg.redisPassword - } - - get didCacheStaleTTL() { - return this.cfg.didCacheStaleTTL - } - - get didCacheMaxTTL() { - return this.cfg.didCacheMaxTTL - } - - get labelCacheStaleTTL() { - return this.cfg.labelCacheStaleTTL - } - - get labelCacheMaxTTL() { - return this.cfg.labelCacheMaxTTL - } - - get handleResolveNameservers() { - return this.cfg.handleResolveNameservers - } - - get didPlcUrl() { - return this.cfg.didPlcUrl - } - - get imgUriEndpoint() { - return this.cfg.imgUriEndpoint - } - - get blobCacheLocation() { - return this.cfg.blobCacheLocation - } - - get searchEndpoint() { - return this.cfg.searchEndpoint + get dataplaneIgnoreBadTls() { + return this.cfg.dataplaneIgnoreBadTls } get bsyncUrl() { @@ -315,10 +165,6 @@ export class ServerConfig { return this.cfg.bsyncApiKey } - get bsyncOnlyMutes() { - return this.cfg.bsyncOnlyMutes - } - get bsyncHttpVersion() { return this.cfg.bsyncHttpVersion } @@ -343,43 +189,39 @@ export class ServerConfig { return this.cfg.courierIgnoreBadTls } - get courierOnlyRegistration() { - return this.cfg.courierOnlyRegistration + get searchUrl() { + return this.cfg.searchUrl } - get adminPassword() { - return this.cfg.adminPassword + get cdnUrl() { + return this.cfg.cdnUrl } - get moderatorPassword() { - return this.cfg.moderatorPassword + get didPlcUrl() { + return this.cfg.didPlcUrl } - get triagePassword() { - return this.cfg.triagePassword + get handleResolveNameservers() { + return this.cfg.handleResolveNameservers } - get modServiceDid() { - return this.cfg.modServiceDid + get adminPasswords() { + return this.cfg.adminPasswords } - get rateLimitsEnabled() { - return this.cfg.rateLimitsEnabled + get modServiceDid() { + return this.cfg.modServiceDid } - get rateLimitBypassKey() { - return this.cfg.rateLimitBypassKey + get labelsFromIssuerDids() { + return this.cfg.labelsFromIssuerDids ?? [] } - get rateLimitBypassIps() { - return this.cfg.rateLimitBypassIps + get blobCacheLocation() { + return this.cfg.blobCacheLocation } } -function getTagIdxs(str?: string): number[] { - return str ? str.split(',').map((item) => parseInt(item, 10)) : [] -} - function stripUndefineds( obj: Record, ): Record { @@ -391,3 +233,8 @@ function stripUndefineds( }) return result } + +function envList(str: string | undefined): string[] { + if (str === undefined || str.length === 0) return [] + return str.split(',') +} diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index db9779e54b9..3b297caf095 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -1,15 +1,12 @@ import * as plc from '@did-plc/lib' import { IdResolver } from '@atproto/identity' -import { AtpAgent } from '@atproto/api' +import AtpAgent from '@atproto/api' import { Keypair } from '@atproto/crypto' import { createServiceJwt } from '@atproto/xrpc-server' -import { DatabaseCoordinator } from './db' import { ServerConfig } from './config' -import { ImageUriBuilder } from './image/uri' -import { Services } from './services' -import DidRedisCache from './did-cache' -import { BackgroundQueue } from './background' -import { Redis } from './redis' +import { DataPlaneClient } from './data-plane/client' +import { Hydrator } from './hydration/hydrator' +import { Views } from './views' import { AuthVerifier } from './auth-verifier' import { BsyncClient } from './bsync' import { CourierClient } from './courier' @@ -17,36 +14,37 @@ import { CourierClient } from './courier' export class AppContext { constructor( private opts: { - db: DatabaseCoordinator - imgUriBuilder: ImageUriBuilder cfg: ServerConfig - services: Services + dataplane: DataPlaneClient + searchAgent: AtpAgent | undefined + hydrator: Hydrator + views: Views signingKey: Keypair idResolver: IdResolver - didCache: DidRedisCache - redis: Redis - backgroundQueue: BackgroundQueue - searchAgent?: AtpAgent - bsyncClient?: BsyncClient - courierClient?: CourierClient + bsyncClient: BsyncClient + courierClient: CourierClient authVerifier: AuthVerifier }, ) {} - get db(): DatabaseCoordinator { - return this.opts.db + get cfg(): ServerConfig { + return this.opts.cfg } - get imgUriBuilder(): ImageUriBuilder { - return this.opts.imgUriBuilder + get dataplane(): DataPlaneClient { + return this.opts.dataplane } - get cfg(): ServerConfig { - return this.opts.cfg + get searchAgent(): AtpAgent | undefined { + return this.opts.searchAgent + } + + get hydrator(): Hydrator { + return this.opts.hydrator } - get services(): Services { - return this.opts.services + get views(): Views { + return this.opts.views } get signingKey(): Keypair { @@ -61,23 +59,11 @@ export class AppContext { return this.opts.idResolver } - get didCache(): DidRedisCache { - return this.opts.didCache - } - - get redis(): Redis { - return this.opts.redis - } - - get searchAgent(): AtpAgent | undefined { - return this.opts.searchAgent - } - - get bsyncClient(): BsyncClient | undefined { + get bsyncClient(): BsyncClient { return this.opts.bsyncClient } - get courierClient(): CourierClient | undefined { + get courierClient(): CourierClient { return this.opts.courierClient } @@ -93,10 +79,6 @@ export class AppContext { keypair: this.signingKey, }) } - - get backgroundQueue(): BackgroundQueue { - return this.opts.backgroundQueue - } } export default AppContext diff --git a/packages/bsky/src/daemon/config.ts b/packages/bsky/src/daemon/config.ts deleted file mode 100644 index 3dd7d557652..00000000000 --- a/packages/bsky/src/daemon/config.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from 'assert' - -export interface DaemonConfigValues { - version: string - dbPostgresUrl: string - dbPostgresSchema?: string - notificationsDaemonFromDid?: string -} - -export class DaemonConfig { - constructor(private cfg: DaemonConfigValues) {} - - static readEnv(overrides?: Partial) { - const version = process.env.BSKY_VERSION || '0.0.0' - const dbPostgresUrl = - overrides?.dbPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL - const dbPostgresSchema = - overrides?.dbPostgresSchema || process.env.DB_POSTGRES_SCHEMA - const notificationsDaemonFromDid = - overrides?.notificationsDaemonFromDid || - process.env.BSKY_NOTIFS_DAEMON_FROM_DID || - undefined - assert(dbPostgresUrl) - return new DaemonConfig({ - version, - dbPostgresUrl, - dbPostgresSchema, - notificationsDaemonFromDid, - ...stripUndefineds(overrides ?? {}), - }) - } - - get version() { - return this.cfg.version - } - - get dbPostgresUrl() { - return this.cfg.dbPostgresUrl - } - - get dbPostgresSchema() { - return this.cfg.dbPostgresSchema - } - - get notificationsDaemonFromDid() { - return this.cfg.notificationsDaemonFromDid - } -} - -function stripUndefineds( - obj: Record, -): Record { - const result = {} - Object.entries(obj).forEach(([key, val]) => { - if (val !== undefined) { - result[key] = val - } - }) - return result -} diff --git a/packages/bsky/src/daemon/context.ts b/packages/bsky/src/daemon/context.ts deleted file mode 100644 index dd3d5c1114f..00000000000 --- a/packages/bsky/src/daemon/context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { PrimaryDatabase } from '../db' -import { DaemonConfig } from './config' -import { Services } from './services' - -export class DaemonContext { - constructor( - private opts: { - db: PrimaryDatabase - cfg: DaemonConfig - services: Services - }, - ) {} - - get db(): PrimaryDatabase { - return this.opts.db - } - - get cfg(): DaemonConfig { - return this.opts.cfg - } - - get services(): Services { - return this.opts.services - } -} - -export default DaemonContext diff --git a/packages/bsky/src/daemon/index.ts b/packages/bsky/src/daemon/index.ts deleted file mode 100644 index 80da01edc2f..00000000000 --- a/packages/bsky/src/daemon/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { PrimaryDatabase } from '../db' -import { dbLogger } from '../logger' -import { DaemonConfig } from './config' -import { DaemonContext } from './context' -import { createServices } from './services' -import { ImageUriBuilder } from '../image/uri' -import { NotificationsDaemon } from './notifications' -import logger from './logger' - -export { DaemonConfig } from './config' -export type { DaemonConfigValues } from './config' - -export class BskyDaemon { - public ctx: DaemonContext - public notifications: NotificationsDaemon - private dbStatsInterval: NodeJS.Timer - private notifStatsInterval: NodeJS.Timer - - constructor(opts: { - ctx: DaemonContext - notifications: NotificationsDaemon - }) { - this.ctx = opts.ctx - this.notifications = opts.notifications - } - - static create(opts: { db: PrimaryDatabase; cfg: DaemonConfig }): BskyDaemon { - const { db, cfg } = opts - const imgUriBuilder = new ImageUriBuilder('https://daemon.invalid') // will not be used by daemon - const services = createServices({ - imgUriBuilder, - }) - const ctx = new DaemonContext({ - db, - cfg, - services, - }) - const notifications = new NotificationsDaemon(ctx) - return new BskyDaemon({ ctx, notifications }) - } - - async start() { - const { db, cfg } = this.ctx - const pool = db.pool - this.notifications.run({ - startFromDid: cfg.notificationsDaemonFromDid, - }) - this.dbStatsInterval = setInterval(() => { - dbLogger.info( - { - idleCount: pool.idleCount, - totalCount: pool.totalCount, - waitingCount: pool.waitingCount, - }, - 'db pool stats', - ) - }, 10000) - this.notifStatsInterval = setInterval(() => { - logger.info( - { - count: this.notifications.count, - lastDid: this.notifications.lastDid, - }, - 'notifications daemon stats', - ) - }, 10000) - return this - } - - async destroy(): Promise { - await this.notifications.destroy() - await this.ctx.db.close() - clearInterval(this.dbStatsInterval) - clearInterval(this.notifStatsInterval) - } -} - -export default BskyDaemon diff --git a/packages/bsky/src/daemon/logger.ts b/packages/bsky/src/daemon/logger.ts deleted file mode 100644 index 8599acc315e..00000000000 --- a/packages/bsky/src/daemon/logger.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { subsystemLogger } from '@atproto/common' - -const logger: ReturnType = - subsystemLogger('bsky:daemon') - -export default logger diff --git a/packages/bsky/src/daemon/notifications.ts b/packages/bsky/src/daemon/notifications.ts deleted file mode 100644 index 96431af8c1f..00000000000 --- a/packages/bsky/src/daemon/notifications.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { tidyNotifications } from '../services/util/notification' -import DaemonContext from './context' -import logger from './logger' - -export class NotificationsDaemon { - ac = new AbortController() - running: Promise | undefined - count = 0 - lastDid: string | null = null - - constructor(private ctx: DaemonContext) {} - - run(opts?: RunOptions) { - if (this.running) return - this.count = 0 - this.lastDid = null - this.ac = new AbortController() - this.running = this.tidyNotifications({ - ...opts, - forever: opts?.forever !== false, // run forever by default - }) - .catch((err) => { - // allow this to cause an unhandled rejection, let deployment handle the crash. - logger.error({ err }, 'notifications daemon crashed') - throw err - }) - .finally(() => (this.running = undefined)) - } - - private async tidyNotifications(opts: RunOptions) { - const actorService = this.ctx.services.actor(this.ctx.db) - for await (const { did } of actorService.all(opts)) { - if (this.ac.signal.aborted) return - try { - await tidyNotifications(this.ctx.db, did) - this.count++ - this.lastDid = did - } catch (err) { - logger.warn({ err, did }, 'failed to tidy notifications for actor') - } - } - } - - async destroy() { - this.ac.abort() - await this.running - } -} - -type RunOptions = { - forever?: boolean - batchSize?: number - startFromDid?: string -} diff --git a/packages/bsky/src/daemon/services.ts b/packages/bsky/src/daemon/services.ts deleted file mode 100644 index 93141d13a08..00000000000 --- a/packages/bsky/src/daemon/services.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PrimaryDatabase } from '../db' -import { ActorService } from '../services/actor' -import { ImageUriBuilder } from '../image/uri' -import { GraphService } from '../services/graph' -import { LabelService } from '../services/label' - -export function createServices(resources: { - imgUriBuilder: ImageUriBuilder -}): Services { - const { imgUriBuilder } = resources - const graph = GraphService.creator(imgUriBuilder) - const label = LabelService.creator(null) - return { - actor: ActorService.creator(imgUriBuilder, graph, label), - } -} - -export type Services = { - actor: FromDbPrimary -} - -type FromDbPrimary = (db: PrimaryDatabase) => T diff --git a/packages/bsky/src/data-plane/bsync/index.ts b/packages/bsky/src/data-plane/bsync/index.ts new file mode 100644 index 00000000000..a1c82bc1126 --- /dev/null +++ b/packages/bsky/src/data-plane/bsync/index.ts @@ -0,0 +1,98 @@ +import http from 'http' +import events from 'events' +import express from 'express' +import { ConnectRouter } from '@connectrpc/connect' +import { expressConnectMiddleware } from '@connectrpc/connect-express' +import { Database } from '../server/db' +import { Service } from '../../proto/bsync_connect' +import { MuteOperation_Type } from '../../proto/bsync_pb' +import assert from 'assert' + +export class MockBsync { + constructor(public server: http.Server) {} + + static async create(db: Database, port: number) { + const app = express() + const routes = createRoutes(db) + app.use(expressConnectMiddleware({ routes })) + const server = app.listen(port) + await events.once(server, 'listening') + return new MockBsync(server) + } + + async destroy() { + return new Promise((resolve, reject) => { + this.server.close((err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } +} + +const createRoutes = (db: Database) => (router: ConnectRouter) => + router.service(Service, { + async addMuteOperation(req) { + const { type, actorDid, subject } = req + if (type === MuteOperation_Type.ADD) { + if (subject.startsWith('did:')) { + assert(actorDid !== subject, 'cannot mute yourself') // @TODO pass message through in http error + await db.db + .insertInto('mute') + .values({ + mutedByDid: actorDid, + subjectDid: subject, + createdAt: new Date().toISOString(), + }) + .onConflict((oc) => oc.doNothing()) + .execute() + } else { + await db.db + .insertInto('list_mute') + .values({ + mutedByDid: actorDid, + listUri: subject, + createdAt: new Date().toISOString(), + }) + .onConflict((oc) => oc.doNothing()) + .execute() + } + } else if (type === MuteOperation_Type.REMOVE) { + if (subject.startsWith('did:')) { + await db.db + .deleteFrom('mute') + .where('mutedByDid', '=', actorDid) + .where('subjectDid', '=', subject) + .execute() + } else { + await db.db + .deleteFrom('list_mute') + .where('mutedByDid', '=', actorDid) + .where('listUri', '=', subject) + .execute() + } + } else if (type === MuteOperation_Type.CLEAR) { + await db.db + .deleteFrom('mute') + .where('mutedByDid', '=', actorDid) + .execute() + await db.db + .deleteFrom('list_mute') + .where('mutedByDid', '=', actorDid) + .execute() + } + + return {} + }, + + async scanMuteOperations() { + throw new Error('not implemented') + }, + + async ping() { + return {} + }, + }) diff --git a/packages/bsky/src/data-plane/client.ts b/packages/bsky/src/data-plane/client.ts new file mode 100644 index 00000000000..dd525267fe3 --- /dev/null +++ b/packages/bsky/src/data-plane/client.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert' +import { randomInt } from 'node:crypto' +import * as ui8 from 'uint8arrays' +import { + Code, + ConnectError, + PromiseClient, + createPromiseClient, + makeAnyClient, +} from '@connectrpc/connect' +import { createGrpcTransport } from '@connectrpc/connect-node' +import { getDidKeyFromMultibase } from '@atproto/identity' +import { Service } from '../proto/bsky_connect' + +export type DataPlaneClient = PromiseClient +type BaseClient = { lib: DataPlaneClient; url: URL } +type HttpVersion = '1.1' | '2' +const MAX_RETRIES = 3 + +export const createDataPlaneClient = ( + baseUrls: string[], + opts: { httpVersion?: HttpVersion; rejectUnauthorized?: boolean }, +) => { + const clients = baseUrls.map((baseUrl) => createBaseClient(baseUrl, opts)) + assert(clients.length > 0, 'no clients available') + return makeAnyClient(Service, (method) => { + return async (...args) => { + let tries = 0 + let error: unknown + let remainingClients = clients + while (tries < MAX_RETRIES) { + const client = randomElement(remainingClients) + assert(client, 'no clients available') + try { + return await client.lib[method.localName](...args) + } catch (err) { + if (err instanceof ConnectError && err.code === Code.Unavailable) { + tries++ + error = err + remainingClients = getRemainingClients(remainingClients, client) + } else { + throw err + } + } + } + assert(error) + throw error + } + }) as DataPlaneClient +} + +export { Code } + +export const isDataplaneError = ( + err: unknown, + code?: Code, +): err is ConnectError => { + if (err instanceof ConnectError) { + return !code || err.code === code + } + return false +} + +const createBaseClient = ( + baseUrl: string, + opts: { httpVersion?: HttpVersion; rejectUnauthorized?: boolean }, +): BaseClient => { + const { httpVersion = '2', rejectUnauthorized = true } = opts + const transport = createGrpcTransport({ + baseUrl, + httpVersion, + acceptCompression: [], + nodeOptions: { rejectUnauthorized }, + }) + return { + lib: createPromiseClient(Service, transport), + url: new URL(baseUrl), + } +} + +const getRemainingClients = (clients: BaseClient[], lastClient: BaseClient) => { + if (clients.length < 2) return clients // no clients to choose from + if (lastClient.url.port) { + // if the last client had a port, we attempt to exclude its whole host. + const maybeRemaining = clients.filter( + (c) => c.url.hostname !== lastClient.url.hostname, + ) + if (maybeRemaining.length) { + return maybeRemaining + } + } + return clients.filter((c) => c !== lastClient) +} + +const randomElement = (arr: T[]): T | undefined => { + if (arr.length === 0) return + return arr[randomInt(arr.length)] +} + +export const unpackIdentityServices = (servicesBytes: Uint8Array) => { + const servicesStr = ui8.toString(servicesBytes, 'utf8') + if (!servicesStr) return {} + return JSON.parse(servicesStr) as UnpackedServices +} + +export const unpackIdentityKeys = (keysBytes: Uint8Array) => { + const keysStr = ui8.toString(keysBytes, 'utf8') + if (!keysStr) return {} + return JSON.parse(keysStr) as UnpackedKeys +} + +export const getServiceEndpoint = ( + services: UnpackedServices, + opts: { id: string; type: string }, +) => { + const endpoint = + services[opts.id] && + services[opts.id].Type === opts.type && + validateUrl(services[opts.id].URL) + return endpoint || undefined +} + +export const getKeyAsDidKey = (keys: UnpackedKeys, opts: { id: string }) => { + const key = + keys[opts.id] && + getDidKeyFromMultibase({ + type: keys[opts.id].Type, + publicKeyMultibase: keys[opts.id].PublicKeyMultibase, + }) + return key || undefined +} + +type UnpackedServices = Record + +type UnpackedKeys = Record + +const validateUrl = (urlStr: string): string | undefined => { + let url + try { + url = new URL(urlStr) + } catch { + return undefined + } + if (!['http:', 'https:'].includes(url.protocol)) { + return undefined + } else if (!url.hostname) { + return undefined + } else { + return urlStr + } +} diff --git a/packages/bsky/src/data-plane/index.ts b/packages/bsky/src/data-plane/index.ts new file mode 100644 index 00000000000..31fe4b0dde3 --- /dev/null +++ b/packages/bsky/src/data-plane/index.ts @@ -0,0 +1,3 @@ +export * from './server' +export * from './client' +export * from './bsync' diff --git a/packages/bsky/src/background.ts b/packages/bsky/src/data-plane/server/background.ts similarity index 76% rename from packages/bsky/src/background.ts rename to packages/bsky/src/data-plane/server/background.ts index 466bad80a51..59d8ccf0ddf 100644 --- a/packages/bsky/src/background.ts +++ b/packages/bsky/src/data-plane/server/background.ts @@ -1,13 +1,13 @@ import PQueue from 'p-queue' -import { PrimaryDatabase } from './db' -import { dbLogger } from './logger' +import { Database } from './db' +import { dbLogger } from '../../logger' // A simple queue for in-process, out-of-band/backgrounded work export class BackgroundQueue { - queue = new PQueue({ concurrency: 20 }) + queue = new PQueue() destroyed = false - constructor(public db: PrimaryDatabase) {} + constructor(public db: Database) {} add(task: Task) { if (this.destroyed) { @@ -32,4 +32,4 @@ export class BackgroundQueue { } } -type Task = (db: PrimaryDatabase) => Promise +type Task = (db: Database) => Promise diff --git a/packages/bsky/src/db/database-schema.ts b/packages/bsky/src/data-plane/server/db/database-schema.ts similarity index 97% rename from packages/bsky/src/db/database-schema.ts rename to packages/bsky/src/data-plane/server/db/database-schema.ts index df28c8b91d8..e02e07f7ad0 100644 --- a/packages/bsky/src/db/database-schema.ts +++ b/packages/bsky/src/data-plane/server/db/database-schema.ts @@ -24,6 +24,7 @@ import * as actorSync from './tables/actor-sync' import * as record from './tables/record' import * as notification from './tables/notification' import * as notificationPushToken from './tables/notification-push-token' +import * as didCache from './tables/did-cache' import * as moderation from './tables/moderation' import * as label from './tables/label' import * as algo from './tables/algo' @@ -58,6 +59,7 @@ export type DatabaseSchemaType = duplicateRecord.PartialDB & record.PartialDB & notification.PartialDB & notificationPushToken.PartialDB & + didCache.PartialDB & moderation.PartialDB & label.PartialDB & algo.PartialDB & diff --git a/packages/bsky/src/db/primary.ts b/packages/bsky/src/data-plane/server/db/db.ts similarity index 55% rename from packages/bsky/src/db/primary.ts rename to packages/bsky/src/data-plane/server/db/db.ts index e6e69872fd5..6411938d69d 100644 --- a/packages/bsky/src/db/primary.ts +++ b/packages/bsky/src/data-plane/server/db/db.ts @@ -1,35 +1,77 @@ +import assert from 'assert' import EventEmitter from 'events' import { - Migrator, + Kysely, KyselyPlugin, + Migrator, PluginTransformQueryArgs, PluginTransformResultArgs, - RootOperationNode, + PostgresDialect, QueryResult, + RootOperationNode, UnknownRow, - sql, } from 'kysely' -import { Pool as PgPool } from 'pg' import TypedEmitter from 'typed-emitter' -import { wait } from '@atproto/common' -import DatabaseSchema from './database-schema' +import { Pool as PgPool, types as pgTypes } from 'pg' import * as migrations from './migrations' -import { CtxMigrationProvider } from './migrations/provider' -import { dbLogger as log } from '../logger' +import DatabaseSchema, { DatabaseSchemaType } from './database-schema' import { PgOptions } from './types' -import { Database } from './db' +import { dbLogger } from '../../../logger' +import { CtxMigrationProvider } from './migrations/provider' -export class PrimaryDatabase extends Database { +export class Database { + pool: PgPool + db: DatabaseSchema migrator: Migrator txEvt = new EventEmitter() as TxnEmitter destroyed = false - isPrimary = true constructor( public opts: PgOptions, - instances?: { db: DatabaseSchema; pool: PgPool }, + instances?: { db: DatabaseSchema; pool: PgPool; migrator: Migrator }, ) { - super(opts, instances) + // if instances are provided, use those + if (instances) { + this.db = instances.db + this.pool = instances.pool + this.migrator = instances.migrator + return + } + + // else create a pool & connect + const { schema, url } = opts + const pool = + opts.pool ?? + new PgPool({ + connectionString: url, + max: opts.poolSize, + maxUses: opts.poolMaxUses, + idleTimeoutMillis: opts.poolIdleTimeoutMs, + }) + + // Select count(*) and other pg bigints as js integer + pgTypes.setTypeParser(pgTypes.builtins.INT8, (n) => parseInt(n, 10)) + + // Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema) + if (schema && !/^[a-z_]+$/i.test(schema)) { + throw new Error(`Postgres schema must only contain [A-Za-z_]: ${schema}`) + } + + pool.on('error', onPoolError) + pool.on('connect', (client) => { + client.on('error', onClientError) + // Used for trigram indexes, e.g. on actor search + client.query('SET pg_trgm.word_similarity_threshold TO .4;') + if (schema) { + // Shared objects such as extensions will go in the public schema + client.query(`SET search_path TO "${schema}",public;`) + } + }) + + this.pool = pool + this.db = new Kysely({ + dialect: new PostgresDialect({ pool }), + }) this.migrator = new Migrator({ db: this.db, migrationTableSchema: opts.schema, @@ -37,23 +79,20 @@ export class PrimaryDatabase extends Database { }) } - static is(db: Database): db is PrimaryDatabase { - return db.isPrimary - } - - asPrimary(): PrimaryDatabase { - return this + get schema(): string | undefined { + return this.opts.schema } - async transaction(fn: (db: PrimaryDatabase) => Promise): Promise { + async transaction(fn: (db: Database) => Promise): Promise { const leakyTxPlugin = new LeakyTxPlugin() const { dbTxn, txRes } = await this.db .withPlugin(leakyTxPlugin) .transaction() .execute(async (txn) => { - const dbTxn = new PrimaryDatabase(this.opts, { + const dbTxn = new Database(this.opts, { db: txn, pool: this.pool, + migrator: this.migrator, }) const txRes = await fn(dbTxn) .catch(async (err) => { @@ -69,17 +108,23 @@ export class PrimaryDatabase extends Database { return txRes } + get isTransaction() { + return this.db.isTransaction + } + + assertTransaction() { + assert(this.isTransaction, 'Transaction required') + } + + assertNotTransaction() { + assert(!this.isTransaction, 'Cannot be in a transaction') + } + onCommit(fn: () => void) { this.assertTransaction() this.txEvt.once('commit', fn) } - async close(): Promise { - if (this.destroyed) return - await this.db.destroy() - this.destroyed = true - } - async migrateToOrThrow(migration: string) { if (this.schema) { await this.db.schema.createSchema(this.schema).ifNotExists().execute() @@ -108,48 +153,17 @@ export class PrimaryDatabase extends Database { return results } - async maintainMaterializedViews(opts: { - views: string[] - intervalSec: number - signal: AbortSignal - }) { - const { views, intervalSec, signal } = opts - while (!signal.aborted) { - // super basic synchronization by agreeing when the intervals land relative to unix timestamp - const now = Date.now() - const intervalMs = 1000 * intervalSec - const nextIteration = Math.ceil(now / intervalMs) - const nextInMs = nextIteration * intervalMs - now - await wait(nextInMs) - if (signal.aborted) break - await Promise.all( - views.map(async (view) => { - try { - await this.refreshMaterializedView(view) - log.info( - { view, time: new Date().toISOString() }, - 'materialized view refreshed', - ) - } catch (err) { - log.error( - { view, err, time: new Date().toISOString() }, - 'materialized view refresh failed', - ) - } - }), - ) - } - } - - async refreshMaterializedView(view: string) { - const { ref } = this.db.dynamic - await sql`refresh materialized view concurrently ${ref(view)}`.execute( - this.db, - ) + async close(): Promise { + if (this.destroyed) return + await this.db.destroy() + this.destroyed = true } } -export default PrimaryDatabase +export default Database + +const onPoolError = (err: Error) => dbLogger.error({ err }, 'db pool error') +const onClientError = (err: Error) => dbLogger.error({ err }, 'db client error') // utils // ------- diff --git a/packages/bsky/src/data-plane/server/db/index.ts b/packages/bsky/src/data-plane/server/db/index.ts new file mode 100644 index 00000000000..1beb455f5e3 --- /dev/null +++ b/packages/bsky/src/data-plane/server/db/index.ts @@ -0,0 +1 @@ +export * from './db' diff --git a/packages/bsky/src/db/migrations/20230309T045948368Z-init.ts b/packages/bsky/src/data-plane/server/db/migrations/20230309T045948368Z-init.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230309T045948368Z-init.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230309T045948368Z-init.ts diff --git a/packages/bsky/src/db/migrations/20230408T152211201Z-notification-init.ts b/packages/bsky/src/data-plane/server/db/migrations/20230408T152211201Z-notification-init.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230408T152211201Z-notification-init.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230408T152211201Z-notification-init.ts diff --git a/packages/bsky/src/db/migrations/20230417T210628672Z-moderation-init.ts b/packages/bsky/src/data-plane/server/db/migrations/20230417T210628672Z-moderation-init.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230417T210628672Z-moderation-init.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230417T210628672Z-moderation-init.ts diff --git a/packages/bsky/src/db/migrations/20230420T211446071Z-did-cache.ts b/packages/bsky/src/data-plane/server/db/migrations/20230420T211446071Z-did-cache.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230420T211446071Z-did-cache.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230420T211446071Z-did-cache.ts diff --git a/packages/bsky/src/db/migrations/20230427T194702079Z-notif-record-index.ts b/packages/bsky/src/data-plane/server/db/migrations/20230427T194702079Z-notif-record-index.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230427T194702079Z-notif-record-index.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230427T194702079Z-notif-record-index.ts diff --git a/packages/bsky/src/db/migrations/20230605T144730094Z-post-profile-aggs.ts b/packages/bsky/src/data-plane/server/db/migrations/20230605T144730094Z-post-profile-aggs.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230605T144730094Z-post-profile-aggs.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230605T144730094Z-post-profile-aggs.ts diff --git a/packages/bsky/src/db/migrations/20230607T211442112Z-feed-generator-init.ts b/packages/bsky/src/data-plane/server/db/migrations/20230607T211442112Z-feed-generator-init.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230607T211442112Z-feed-generator-init.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230607T211442112Z-feed-generator-init.ts diff --git a/packages/bsky/src/db/migrations/20230608T155101190Z-algo-whats-hot-view.ts b/packages/bsky/src/data-plane/server/db/migrations/20230608T155101190Z-algo-whats-hot-view.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230608T155101190Z-algo-whats-hot-view.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230608T155101190Z-algo-whats-hot-view.ts diff --git a/packages/bsky/src/db/migrations/20230608T201813132Z-mute-lists.ts b/packages/bsky/src/data-plane/server/db/migrations/20230608T201813132Z-mute-lists.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230608T201813132Z-mute-lists.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230608T201813132Z-mute-lists.ts diff --git a/packages/bsky/src/db/migrations/20230608T205147239Z-mutes.ts b/packages/bsky/src/data-plane/server/db/migrations/20230608T205147239Z-mutes.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230608T205147239Z-mutes.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230608T205147239Z-mutes.ts diff --git a/packages/bsky/src/db/migrations/20230609T153623961Z-blocks.ts b/packages/bsky/src/data-plane/server/db/migrations/20230609T153623961Z-blocks.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230609T153623961Z-blocks.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230609T153623961Z-blocks.ts diff --git a/packages/bsky/src/db/migrations/20230609T232122649Z-actor-deletion-indexes.ts b/packages/bsky/src/data-plane/server/db/migrations/20230609T232122649Z-actor-deletion-indexes.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230609T232122649Z-actor-deletion-indexes.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230609T232122649Z-actor-deletion-indexes.ts diff --git a/packages/bsky/src/db/migrations/20230610T203555962Z-suggested-follows.ts b/packages/bsky/src/data-plane/server/db/migrations/20230610T203555962Z-suggested-follows.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230610T203555962Z-suggested-follows.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230610T203555962Z-suggested-follows.ts diff --git a/packages/bsky/src/db/migrations/20230611T215300060Z-actor-state.ts b/packages/bsky/src/data-plane/server/db/migrations/20230611T215300060Z-actor-state.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230611T215300060Z-actor-state.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230611T215300060Z-actor-state.ts diff --git a/packages/bsky/src/db/migrations/20230620T161134972Z-post-langs.ts b/packages/bsky/src/data-plane/server/db/migrations/20230620T161134972Z-post-langs.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230620T161134972Z-post-langs.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230620T161134972Z-post-langs.ts diff --git a/packages/bsky/src/db/migrations/20230627T212437895Z-optional-handle.ts b/packages/bsky/src/data-plane/server/db/migrations/20230627T212437895Z-optional-handle.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230627T212437895Z-optional-handle.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230627T212437895Z-optional-handle.ts diff --git a/packages/bsky/src/db/migrations/20230629T220835893Z-remove-post-hierarchy.ts b/packages/bsky/src/data-plane/server/db/migrations/20230629T220835893Z-remove-post-hierarchy.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230629T220835893Z-remove-post-hierarchy.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230629T220835893Z-remove-post-hierarchy.ts diff --git a/packages/bsky/src/db/migrations/20230703T045536691Z-feed-and-label-indices.ts b/packages/bsky/src/data-plane/server/db/migrations/20230703T045536691Z-feed-and-label-indices.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230703T045536691Z-feed-and-label-indices.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230703T045536691Z-feed-and-label-indices.ts diff --git a/packages/bsky/src/db/migrations/20230720T164800037Z-posts-cursor-idx.ts b/packages/bsky/src/data-plane/server/db/migrations/20230720T164800037Z-posts-cursor-idx.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230720T164800037Z-posts-cursor-idx.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230720T164800037Z-posts-cursor-idx.ts diff --git a/packages/bsky/src/db/migrations/20230807T035309811Z-feed-item-delete-invite-for-user-idx.ts b/packages/bsky/src/data-plane/server/db/migrations/20230807T035309811Z-feed-item-delete-invite-for-user-idx.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230807T035309811Z-feed-item-delete-invite-for-user-idx.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230807T035309811Z-feed-item-delete-invite-for-user-idx.ts diff --git a/packages/bsky/src/db/migrations/20230808T172902639Z-repo-rev.ts b/packages/bsky/src/data-plane/server/db/migrations/20230808T172902639Z-repo-rev.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230808T172902639Z-repo-rev.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230808T172902639Z-repo-rev.ts diff --git a/packages/bsky/src/db/migrations/20230810T203349843Z-action-duration.ts b/packages/bsky/src/data-plane/server/db/migrations/20230810T203349843Z-action-duration.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230810T203349843Z-action-duration.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230810T203349843Z-action-duration.ts diff --git a/packages/bsky/src/db/migrations/20230817T195936007Z-native-notifications.ts b/packages/bsky/src/data-plane/server/db/migrations/20230817T195936007Z-native-notifications.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230817T195936007Z-native-notifications.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230817T195936007Z-native-notifications.ts diff --git a/packages/bsky/src/db/migrations/20230830T205507322Z-suggested-feeds.ts b/packages/bsky/src/data-plane/server/db/migrations/20230830T205507322Z-suggested-feeds.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230830T205507322Z-suggested-feeds.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230830T205507322Z-suggested-feeds.ts diff --git a/packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts b/packages/bsky/src/data-plane/server/db/migrations/20230904T211011773Z-block-lists.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230904T211011773Z-block-lists.ts diff --git a/packages/bsky/src/db/migrations/20230906T222220386Z-thread-gating.ts b/packages/bsky/src/data-plane/server/db/migrations/20230906T222220386Z-thread-gating.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230906T222220386Z-thread-gating.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230906T222220386Z-thread-gating.ts diff --git a/packages/bsky/src/db/migrations/20230920T213858047Z-add-tags-to-post.ts b/packages/bsky/src/data-plane/server/db/migrations/20230920T213858047Z-add-tags-to-post.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230920T213858047Z-add-tags-to-post.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230920T213858047Z-add-tags-to-post.ts diff --git a/packages/bsky/src/db/migrations/20230929T192920807Z-record-cursor-indexes.ts b/packages/bsky/src/data-plane/server/db/migrations/20230929T192920807Z-record-cursor-indexes.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230929T192920807Z-record-cursor-indexes.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230929T192920807Z-record-cursor-indexes.ts diff --git a/packages/bsky/src/db/migrations/20231003T202833377Z-create-moderation-subject-status.ts b/packages/bsky/src/data-plane/server/db/migrations/20231003T202833377Z-create-moderation-subject-status.ts similarity index 100% rename from packages/bsky/src/db/migrations/20231003T202833377Z-create-moderation-subject-status.ts rename to packages/bsky/src/data-plane/server/db/migrations/20231003T202833377Z-create-moderation-subject-status.ts diff --git a/packages/bsky/src/db/migrations/20231220T225126090Z-blob-takedowns.ts b/packages/bsky/src/data-plane/server/db/migrations/20231220T225126090Z-blob-takedowns.ts similarity index 100% rename from packages/bsky/src/db/migrations/20231220T225126090Z-blob-takedowns.ts rename to packages/bsky/src/data-plane/server/db/migrations/20231220T225126090Z-blob-takedowns.ts diff --git a/packages/bsky/src/db/migrations/20240124T023719200Z-tagged-suggestions.ts b/packages/bsky/src/data-plane/server/db/migrations/20240124T023719200Z-tagged-suggestions.ts similarity index 100% rename from packages/bsky/src/db/migrations/20240124T023719200Z-tagged-suggestions.ts rename to packages/bsky/src/data-plane/server/db/migrations/20240124T023719200Z-tagged-suggestions.ts diff --git a/packages/bsky/src/db/migrations/index.ts b/packages/bsky/src/data-plane/server/db/migrations/index.ts similarity index 97% rename from packages/bsky/src/db/migrations/index.ts rename to packages/bsky/src/data-plane/server/db/migrations/index.ts index e5a5b155e71..03f8795b3f7 100644 --- a/packages/bsky/src/db/migrations/index.ts +++ b/packages/bsky/src/data-plane/server/db/migrations/index.ts @@ -31,6 +31,5 @@ export * as _20230906T222220386Z from './20230906T222220386Z-thread-gating' export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post' export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-indexes' export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status' -export * as _20231205T000257238Z from './20231205T000257238Z-remove-did-cache' export * as _20231220T225126090Z from './20231220T225126090Z-blob-takedowns' export * as _20240124T023719200Z from './20240124T023719200Z-tagged-suggestions' diff --git a/packages/bsky/src/db/migrations/provider.ts b/packages/bsky/src/data-plane/server/db/migrations/provider.ts similarity index 100% rename from packages/bsky/src/db/migrations/provider.ts rename to packages/bsky/src/data-plane/server/db/migrations/provider.ts diff --git a/packages/bsky/src/db/pagination.ts b/packages/bsky/src/data-plane/server/db/pagination.ts similarity index 88% rename from packages/bsky/src/db/pagination.ts rename to packages/bsky/src/data-plane/server/db/pagination.ts index f08702cb003..d4c5747e2b4 100644 --- a/packages/bsky/src/db/pagination.ts +++ b/packages/bsky/src/data-plane/server/db/pagination.ts @@ -16,7 +16,7 @@ export type LabeledResult = { * - LabeledResult: a Result processed such that the "primary" and "secondary" parts of the cursor are labeled. * - E.g. { primary: '2022-01-01T12:00:00Z', secondary: 'bafyx' } * - Cursor: the two string parts that make-up the packed/string cursor. - * - E.g. packed cursor '1641038400000::bafyx' in parts { primary: '1641038400000', secondary: 'bafyx' } + * - E.g. packed cursor '1641038400000__bafyx' in parts { primary: '1641038400000', secondary: 'bafyx' } * * These types relate as such. Implementers define the relations marked with a *: * Result -*-> LabeledResult <-*-> Cursor <--> packed/string cursor @@ -27,9 +27,6 @@ export abstract class GenericKeyset { abstract labelResult(result: R): LR abstract labeledResultToCursor(labeled: LR): Cursor abstract cursorToLabeledResult(cursor: Cursor): LR - static clearlyBad(cursor?: string) { - return cursor !== undefined && !cursor.includes('::') - } packFromResult(results: R | R[]): string | undefined { const result = Array.isArray(results) ? results.at(-1) : results if (!result) return @@ -47,11 +44,11 @@ export abstract class GenericKeyset { } packCursor(cursor?: Cursor): string | undefined { if (!cursor) return - return `${cursor.primary}::${cursor.secondary}` + return `${cursor.primary}__${cursor.secondary}` } unpackCursor(cursorStr?: string): Cursor | undefined { if (!cursorStr) return - const result = cursorStr.split('::') + const result = cursorStr.split('__') const [primary, secondary, ...others] = result if (!primary || !secondary || others.length > 0) { throw new InvalidRequestError('Malformed cursor') @@ -109,6 +106,24 @@ export class TimeCidKeyset< } } +export class CreatedAtDidKeyset extends TimeCidKeyset<{ + createdAt: string + did: string // dids are treated identically to cids in TimeCidKeyset +}> { + labelResult(result: { createdAt: string; did: string }) { + return { primary: result.createdAt, secondary: result.did } + } +} + +export class IndexedAtDidKeyset extends TimeCidKeyset<{ + indexedAt: string + did: string // dids are treated identically to cids in TimeCidKeyset +}> { + labelResult(result: { indexedAt: string; did: string }) { + return { primary: result.indexedAt, secondary: result.did } + } +} + export const paginate = < QB extends AnyQb, K extends GenericKeyset, diff --git a/packages/bsky/src/db/tables/actor-block.ts b/packages/bsky/src/data-plane/server/db/tables/actor-block.ts similarity index 100% rename from packages/bsky/src/db/tables/actor-block.ts rename to packages/bsky/src/data-plane/server/db/tables/actor-block.ts diff --git a/packages/bsky/src/db/tables/actor-state.ts b/packages/bsky/src/data-plane/server/db/tables/actor-state.ts similarity index 100% rename from packages/bsky/src/db/tables/actor-state.ts rename to packages/bsky/src/data-plane/server/db/tables/actor-state.ts diff --git a/packages/bsky/src/db/tables/actor-sync.ts b/packages/bsky/src/data-plane/server/db/tables/actor-sync.ts similarity index 100% rename from packages/bsky/src/db/tables/actor-sync.ts rename to packages/bsky/src/data-plane/server/db/tables/actor-sync.ts diff --git a/packages/bsky/src/db/tables/actor.ts b/packages/bsky/src/data-plane/server/db/tables/actor.ts similarity index 100% rename from packages/bsky/src/db/tables/actor.ts rename to packages/bsky/src/data-plane/server/db/tables/actor.ts diff --git a/packages/bsky/src/db/tables/algo.ts b/packages/bsky/src/data-plane/server/db/tables/algo.ts similarity index 100% rename from packages/bsky/src/db/tables/algo.ts rename to packages/bsky/src/data-plane/server/db/tables/algo.ts diff --git a/packages/bsky/src/db/tables/blob-takedown.ts b/packages/bsky/src/data-plane/server/db/tables/blob-takedown.ts similarity index 100% rename from packages/bsky/src/db/tables/blob-takedown.ts rename to packages/bsky/src/data-plane/server/db/tables/blob-takedown.ts diff --git a/packages/bsky/src/data-plane/server/db/tables/did-cache.ts b/packages/bsky/src/data-plane/server/db/tables/did-cache.ts new file mode 100644 index 00000000000..b3865548725 --- /dev/null +++ b/packages/bsky/src/data-plane/server/db/tables/did-cache.ts @@ -0,0 +1,13 @@ +import { DidDocument } from '@atproto/identity' + +export interface DidCache { + did: string + doc: DidDocument + updatedAt: number +} + +export const tableName = 'did_cache' + +export type PartialDB = { + [tableName]: DidCache +} diff --git a/packages/bsky/src/db/tables/duplicate-record.ts b/packages/bsky/src/data-plane/server/db/tables/duplicate-record.ts similarity index 100% rename from packages/bsky/src/db/tables/duplicate-record.ts rename to packages/bsky/src/data-plane/server/db/tables/duplicate-record.ts diff --git a/packages/bsky/src/db/tables/feed-generator.ts b/packages/bsky/src/data-plane/server/db/tables/feed-generator.ts similarity index 100% rename from packages/bsky/src/db/tables/feed-generator.ts rename to packages/bsky/src/data-plane/server/db/tables/feed-generator.ts diff --git a/packages/bsky/src/db/tables/feed-item.ts b/packages/bsky/src/data-plane/server/db/tables/feed-item.ts similarity index 100% rename from packages/bsky/src/db/tables/feed-item.ts rename to packages/bsky/src/data-plane/server/db/tables/feed-item.ts diff --git a/packages/bsky/src/db/tables/follow.ts b/packages/bsky/src/data-plane/server/db/tables/follow.ts similarity index 100% rename from packages/bsky/src/db/tables/follow.ts rename to packages/bsky/src/data-plane/server/db/tables/follow.ts diff --git a/packages/bsky/src/db/tables/label.ts b/packages/bsky/src/data-plane/server/db/tables/label.ts similarity index 100% rename from packages/bsky/src/db/tables/label.ts rename to packages/bsky/src/data-plane/server/db/tables/label.ts diff --git a/packages/bsky/src/db/tables/like.ts b/packages/bsky/src/data-plane/server/db/tables/like.ts similarity index 100% rename from packages/bsky/src/db/tables/like.ts rename to packages/bsky/src/data-plane/server/db/tables/like.ts diff --git a/packages/bsky/src/db/tables/list-block.ts b/packages/bsky/src/data-plane/server/db/tables/list-block.ts similarity index 100% rename from packages/bsky/src/db/tables/list-block.ts rename to packages/bsky/src/data-plane/server/db/tables/list-block.ts diff --git a/packages/bsky/src/db/tables/list-item.ts b/packages/bsky/src/data-plane/server/db/tables/list-item.ts similarity index 100% rename from packages/bsky/src/db/tables/list-item.ts rename to packages/bsky/src/data-plane/server/db/tables/list-item.ts diff --git a/packages/bsky/src/db/tables/list-mute.ts b/packages/bsky/src/data-plane/server/db/tables/list-mute.ts similarity index 100% rename from packages/bsky/src/db/tables/list-mute.ts rename to packages/bsky/src/data-plane/server/db/tables/list-mute.ts diff --git a/packages/bsky/src/db/tables/list.ts b/packages/bsky/src/data-plane/server/db/tables/list.ts similarity index 100% rename from packages/bsky/src/db/tables/list.ts rename to packages/bsky/src/data-plane/server/db/tables/list.ts diff --git a/packages/bsky/src/db/tables/moderation.ts b/packages/bsky/src/data-plane/server/db/tables/moderation.ts similarity index 96% rename from packages/bsky/src/db/tables/moderation.ts rename to packages/bsky/src/data-plane/server/db/tables/moderation.ts index f1ac3572785..c483ae20a4c 100644 --- a/packages/bsky/src/db/tables/moderation.ts +++ b/packages/bsky/src/data-plane/server/db/tables/moderation.ts @@ -3,7 +3,7 @@ import { REVIEWCLOSED, REVIEWOPEN, REVIEWESCALATED, -} from '../../lexicon/types/com/atproto/admin/defs' +} from '../../../../lexicon/types/com/atproto/admin/defs' export const eventTableName = 'moderation_event' export const subjectStatusTableName = 'moderation_subject_status' diff --git a/packages/bsky/src/db/tables/mute.ts b/packages/bsky/src/data-plane/server/db/tables/mute.ts similarity index 100% rename from packages/bsky/src/db/tables/mute.ts rename to packages/bsky/src/data-plane/server/db/tables/mute.ts diff --git a/packages/bsky/src/db/tables/notification-push-token.ts b/packages/bsky/src/data-plane/server/db/tables/notification-push-token.ts similarity index 100% rename from packages/bsky/src/db/tables/notification-push-token.ts rename to packages/bsky/src/data-plane/server/db/tables/notification-push-token.ts diff --git a/packages/bsky/src/db/tables/notification.ts b/packages/bsky/src/data-plane/server/db/tables/notification.ts similarity index 100% rename from packages/bsky/src/db/tables/notification.ts rename to packages/bsky/src/data-plane/server/db/tables/notification.ts diff --git a/packages/bsky/src/db/tables/post-agg.ts b/packages/bsky/src/data-plane/server/db/tables/post-agg.ts similarity index 100% rename from packages/bsky/src/db/tables/post-agg.ts rename to packages/bsky/src/data-plane/server/db/tables/post-agg.ts diff --git a/packages/bsky/src/db/tables/post-embed.ts b/packages/bsky/src/data-plane/server/db/tables/post-embed.ts similarity index 100% rename from packages/bsky/src/db/tables/post-embed.ts rename to packages/bsky/src/data-plane/server/db/tables/post-embed.ts diff --git a/packages/bsky/src/db/tables/post.ts b/packages/bsky/src/data-plane/server/db/tables/post.ts similarity index 100% rename from packages/bsky/src/db/tables/post.ts rename to packages/bsky/src/data-plane/server/db/tables/post.ts diff --git a/packages/bsky/src/db/tables/profile-agg.ts b/packages/bsky/src/data-plane/server/db/tables/profile-agg.ts similarity index 100% rename from packages/bsky/src/db/tables/profile-agg.ts rename to packages/bsky/src/data-plane/server/db/tables/profile-agg.ts diff --git a/packages/bsky/src/db/tables/profile.ts b/packages/bsky/src/data-plane/server/db/tables/profile.ts similarity index 100% rename from packages/bsky/src/db/tables/profile.ts rename to packages/bsky/src/data-plane/server/db/tables/profile.ts diff --git a/packages/bsky/src/db/tables/record.ts b/packages/bsky/src/data-plane/server/db/tables/record.ts similarity index 100% rename from packages/bsky/src/db/tables/record.ts rename to packages/bsky/src/data-plane/server/db/tables/record.ts diff --git a/packages/bsky/src/db/tables/repost.ts b/packages/bsky/src/data-plane/server/db/tables/repost.ts similarity index 100% rename from packages/bsky/src/db/tables/repost.ts rename to packages/bsky/src/data-plane/server/db/tables/repost.ts diff --git a/packages/bsky/src/db/tables/subscription.ts b/packages/bsky/src/data-plane/server/db/tables/subscription.ts similarity index 100% rename from packages/bsky/src/db/tables/subscription.ts rename to packages/bsky/src/data-plane/server/db/tables/subscription.ts diff --git a/packages/bsky/src/db/tables/suggested-feed.ts b/packages/bsky/src/data-plane/server/db/tables/suggested-feed.ts similarity index 100% rename from packages/bsky/src/db/tables/suggested-feed.ts rename to packages/bsky/src/data-plane/server/db/tables/suggested-feed.ts diff --git a/packages/bsky/src/db/tables/suggested-follow.ts b/packages/bsky/src/data-plane/server/db/tables/suggested-follow.ts similarity index 100% rename from packages/bsky/src/db/tables/suggested-follow.ts rename to packages/bsky/src/data-plane/server/db/tables/suggested-follow.ts diff --git a/packages/bsky/src/db/tables/tagged-suggestion.ts b/packages/bsky/src/data-plane/server/db/tables/tagged-suggestion.ts similarity index 100% rename from packages/bsky/src/db/tables/tagged-suggestion.ts rename to packages/bsky/src/data-plane/server/db/tables/tagged-suggestion.ts diff --git a/packages/bsky/src/db/tables/thread-gate.ts b/packages/bsky/src/data-plane/server/db/tables/thread-gate.ts similarity index 100% rename from packages/bsky/src/db/tables/thread-gate.ts rename to packages/bsky/src/data-plane/server/db/tables/thread-gate.ts diff --git a/packages/bsky/src/db/tables/view-param.ts b/packages/bsky/src/data-plane/server/db/tables/view-param.ts similarity index 100% rename from packages/bsky/src/db/tables/view-param.ts rename to packages/bsky/src/data-plane/server/db/tables/view-param.ts diff --git a/packages/bsky/src/db/types.ts b/packages/bsky/src/data-plane/server/db/types.ts similarity index 100% rename from packages/bsky/src/db/types.ts rename to packages/bsky/src/data-plane/server/db/types.ts diff --git a/packages/bsky/src/db/util.ts b/packages/bsky/src/data-plane/server/db/util.ts similarity index 100% rename from packages/bsky/src/db/util.ts rename to packages/bsky/src/data-plane/server/db/util.ts diff --git a/packages/bsky/src/data-plane/server/index.ts b/packages/bsky/src/data-plane/server/index.ts new file mode 100644 index 00000000000..f925de83c48 --- /dev/null +++ b/packages/bsky/src/data-plane/server/index.ts @@ -0,0 +1,36 @@ +import http from 'http' +import events from 'events' +import express from 'express' +import { expressConnectMiddleware } from '@connectrpc/connect-express' +import createRoutes from './routes' +import { Database } from './db' +import { IdResolver, MemoryCache } from '@atproto/identity' + +export { RepoSubscription } from './subscription' + +export class DataPlaneServer { + constructor(public server: http.Server, public idResolver: IdResolver) {} + + static async create(db: Database, port: number, plcUrl?: string) { + const app = express() + const didCache = new MemoryCache() + const idResolver = new IdResolver({ plcUrl, didCache }) + const routes = createRoutes(db, idResolver) + app.use(expressConnectMiddleware({ routes })) + const server = app.listen(port) + await events.once(server, 'listening') + return new DataPlaneServer(server, idResolver) + } + + async destroy() { + return new Promise((resolve, reject) => { + this.server.close((err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } +} diff --git a/packages/bsky/src/services/indexing/index.ts b/packages/bsky/src/data-plane/server/indexing/index.ts similarity index 86% rename from packages/bsky/src/services/indexing/index.ts rename to packages/bsky/src/data-plane/server/indexing/index.ts index 44dd9c3c986..68f5ff8b721 100644 --- a/packages/bsky/src/services/indexing/index.ts +++ b/packages/bsky/src/data-plane/server/indexing/index.ts @@ -13,7 +13,8 @@ import { AtUri } from '@atproto/syntax' import { IdResolver, getPds } from '@atproto/identity' import { DAY, HOUR } from '@atproto/common' import { ValidationError } from '@atproto/lexicon' -import { PrimaryDatabase } from '../../db' +import { Database } from '../db' +import { Actor } from '../db/tables/actor' import * as Post from './plugins/post' import * as Threadgate from './plugins/thread-gate' import * as Like from './plugins/like' @@ -26,12 +27,9 @@ import * as ListBlock from './plugins/list-block' import * as Block from './plugins/block' import * as FeedGenerator from './plugins/feed-generator' import RecordProcessor from './processor' -import { subLogger } from '../../logger' -import { retryHttp } from '../../util/retry' -import { BackgroundQueue } from '../../background' -import { NotificationServer } from '../../notifications' -import { AutoModerator } from '../../auto-moderator' -import { Actor } from '../../db/tables/actor' +import { subLogger } from '../../../logger' +import { retryHttp } from '../../../util/retry' +import { BackgroundQueue } from '../background' export class IndexingService { records: { @@ -49,50 +47,28 @@ export class IndexingService { } constructor( - public db: PrimaryDatabase, + public db: Database, public idResolver: IdResolver, - public autoMod: AutoModerator, - public backgroundQueue: BackgroundQueue, - public notifServer?: NotificationServer, + public background: BackgroundQueue, ) { this.records = { - post: Post.makePlugin(this.db, backgroundQueue, notifServer), - threadGate: Threadgate.makePlugin(this.db, backgroundQueue, notifServer), - like: Like.makePlugin(this.db, backgroundQueue, notifServer), - repost: Repost.makePlugin(this.db, backgroundQueue, notifServer), - follow: Follow.makePlugin(this.db, backgroundQueue, notifServer), - profile: Profile.makePlugin(this.db, backgroundQueue, notifServer), - list: List.makePlugin(this.db, backgroundQueue, notifServer), - listItem: ListItem.makePlugin(this.db, backgroundQueue, notifServer), - listBlock: ListBlock.makePlugin(this.db, backgroundQueue, notifServer), - block: Block.makePlugin(this.db, backgroundQueue, notifServer), - feedGenerator: FeedGenerator.makePlugin( - this.db, - backgroundQueue, - notifServer, - ), + post: Post.makePlugin(this.db, this.background), + threadGate: Threadgate.makePlugin(this.db, this.background), + like: Like.makePlugin(this.db, this.background), + repost: Repost.makePlugin(this.db, this.background), + follow: Follow.makePlugin(this.db, this.background), + profile: Profile.makePlugin(this.db, this.background), + list: List.makePlugin(this.db, this.background), + listItem: ListItem.makePlugin(this.db, this.background), + listBlock: ListBlock.makePlugin(this.db, this.background), + block: Block.makePlugin(this.db, this.background), + feedGenerator: FeedGenerator.makePlugin(this.db, this.background), } } - transact(txn: PrimaryDatabase) { + transact(txn: Database) { txn.assertTransaction() - return new IndexingService( - txn, - this.idResolver, - this.autoMod, - this.backgroundQueue, - this.notifServer, - ) - } - - static creator( - idResolver: IdResolver, - autoMod: AutoModerator, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, - ) { - return (db: PrimaryDatabase) => - new IndexingService(db, idResolver, autoMod, backgroundQueue, notifServer) + return new IndexingService(txn, this.idResolver, this.background) } async indexRecord( @@ -114,9 +90,6 @@ export class IndexingService { await indexer.updateRecord(uri, cid, obj, timestamp) } }) - if (!opts?.disableLabels) { - this.autoMod.processRecord(uri, cid, obj) - } } async deleteRecord(uri: AtUri, cascading = false) { @@ -170,10 +143,6 @@ export class IndexingService { .onConflict((oc) => oc.column('did').doUpdateSet(actorInfo)) .returning('did') .executeTakeFirst() - - if (handle) { - this.autoMod.processHandle(handle, did) - } } async indexRepo(did: string, commit?: string) { diff --git a/packages/bsky/src/services/indexing/plugins/block.ts b/packages/bsky/src/data-plane/server/indexing/plugins/block.ts similarity index 77% rename from packages/bsky/src/services/indexing/plugins/block.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/block.ts index 88e62b6f5ac..ec4956a04f5 100644 --- a/packages/bsky/src/services/indexing/plugins/block.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/block.ts @@ -1,13 +1,12 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as Block from '../../../lexicon/types/app/bsky/graph/block' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Block from '../../../../lexicon/types/app/bsky/graph/block' +import * as lex from '../../../../lexicon/lexicons' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphBlock type IndexedBlock = Selectable @@ -72,11 +71,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/feed-generator.ts b/packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts similarity index 77% rename from packages/bsky/src/services/indexing/plugins/feed-generator.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts index be5435966f1..f3b82c75567 100644 --- a/packages/bsky/src/services/indexing/plugins/feed-generator.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts @@ -1,13 +1,12 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as FeedGenerator from '../../../lexicon/types/app/bsky/feed/generator' -import * as lex from '../../../lexicon/lexicons' -import { PrimaryDatabase } from '../../../db' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' -import { BackgroundQueue } from '../../../background' +import * as FeedGenerator from '../../../../lexicon/types/app/bsky/feed/generator' +import * as lex from '../../../../lexicon/lexicons' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { NotificationServer } from '../../../notifications' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyFeedGenerator type IndexedFeedGenerator = Selectable @@ -71,11 +70,10 @@ export type PluginType = RecordProcessor< > export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/follow.ts b/packages/bsky/src/data-plane/server/indexing/plugins/follow.ts similarity index 84% rename from packages/bsky/src/services/indexing/plugins/follow.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/follow.ts index 8655c7eba71..6f238755761 100644 --- a/packages/bsky/src/services/indexing/plugins/follow.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/follow.ts @@ -1,14 +1,13 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as Follow from '../../../lexicon/types/app/bsky/graph/follow' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Follow from '../../../../lexicon/types/app/bsky/graph/follow' +import * as lex from '../../../../lexicon/lexicons' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { countAll, excluded } from '../../../db/util' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { countAll, excluded } from '../../db/util' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphFollow type IndexedFollow = Selectable @@ -119,11 +118,10 @@ const updateAggregates = async (db: DatabaseSchema, follow: IndexedFollow) => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/like.ts b/packages/bsky/src/data-plane/server/indexing/plugins/like.ts similarity index 83% rename from packages/bsky/src/services/indexing/plugins/like.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/like.ts index 703800f67c8..98e9fc722f8 100644 --- a/packages/bsky/src/services/indexing/plugins/like.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/like.ts @@ -1,14 +1,13 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as Like from '../../../lexicon/types/app/bsky/feed/like' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Like from '../../../../lexicon/types/app/bsky/feed/like' +import * as lex from '../../../../lexicon/lexicons' import RecordProcessor from '../processor' -import { countAll, excluded } from '../../../db/util' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { countAll, excluded } from '../../db/util' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyFeedLike type IndexedLike = Selectable @@ -109,11 +108,10 @@ const updateAggregates = async (db: DatabaseSchema, like: IndexedLike) => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/list-block.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts similarity index 77% rename from packages/bsky/src/services/indexing/plugins/list-block.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts index 3040f1aa3f9..09eabcdb9f4 100644 --- a/packages/bsky/src/services/indexing/plugins/list-block.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts @@ -1,13 +1,12 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as ListBlock from '../../../lexicon/types/app/bsky/graph/listblock' -import * as lex from '../../../lexicon/lexicons' -import { PrimaryDatabase } from '../../../db' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as ListBlock from '../../../../lexicon/types/app/bsky/graph/listblock' +import * as lex from '../../../../lexicon/lexicons' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphListblock type IndexedListBlock = Selectable @@ -72,11 +71,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/list-item.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts similarity index 79% rename from packages/bsky/src/services/indexing/plugins/list-item.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts index 9e08145b23e..f2a43cff485 100644 --- a/packages/bsky/src/services/indexing/plugins/list-item.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts @@ -1,14 +1,13 @@ import { Selectable } from 'kysely' +import { InvalidRequestError } from '@atproto/xrpc-server' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as ListItem from '../../../lexicon/types/app/bsky/graph/listitem' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as ListItem from '../../../../lexicon/types/app/bsky/graph/listitem' +import * as lex from '../../../../lexicon/lexicons' import RecordProcessor from '../processor' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphListitem type IndexedListItem = Selectable @@ -80,11 +79,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/list.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list.ts similarity index 77% rename from packages/bsky/src/services/indexing/plugins/list.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/list.ts index 0d078572501..f6deaf0a68e 100644 --- a/packages/bsky/src/services/indexing/plugins/list.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list.ts @@ -1,13 +1,12 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as List from '../../../lexicon/types/app/bsky/graph/list' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as List from '../../../../lexicon/types/app/bsky/graph/list' +import * as lex from '../../../../lexicon/lexicons' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphList type IndexedList = Selectable @@ -68,11 +67,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/post.ts b/packages/bsky/src/data-plane/server/indexing/plugins/post.ts similarity index 89% rename from packages/bsky/src/services/indexing/plugins/post.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/post.ts index af581b3bdff..cc4121ab667 100644 --- a/packages/bsky/src/services/indexing/plugins/post.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/post.ts @@ -5,27 +5,30 @@ import { jsonStringToLex } from '@atproto/lexicon' import { Record as PostRecord, ReplyRef, -} from '../../../lexicon/types/app/bsky/feed/post' -import { Record as GateRecord } from '../../../lexicon/types/app/bsky/feed/threadgate' -import { isMain as isEmbedImage } from '../../../lexicon/types/app/bsky/embed/images' -import { isMain as isEmbedExternal } from '../../../lexicon/types/app/bsky/embed/external' -import { isMain as isEmbedRecord } from '../../../lexicon/types/app/bsky/embed/record' -import { isMain as isEmbedRecordWithMedia } from '../../../lexicon/types/app/bsky/embed/recordWithMedia' +} from '../../../../lexicon/types/app/bsky/feed/post' +import { Record as GateRecord } from '../../../../lexicon/types/app/bsky/feed/threadgate' +import { isMain as isEmbedImage } from '../../../../lexicon/types/app/bsky/embed/images' +import { isMain as isEmbedExternal } from '../../../../lexicon/types/app/bsky/embed/external' +import { isMain as isEmbedRecord } from '../../../../lexicon/types/app/bsky/embed/record' +import { isMain as isEmbedRecordWithMedia } from '../../../../lexicon/types/app/bsky/embed/recordWithMedia' import { isMention, isLink, -} from '../../../lexicon/types/app/bsky/richtext/facet' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +} from '../../../../lexicon/types/app/bsky/richtext/facet' +import * as lex from '../../../../lexicon/lexicons' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { Notification } from '../../../db/tables/notification' -import { PrimaryDatabase } from '../../../db' -import { countAll, excluded } from '../../../db/util' -import { BackgroundQueue } from '../../../background' -import { getAncestorsAndSelfQb, getDescendentsQb } from '../../util/post' -import { NotificationServer } from '../../../notifications' -import * as feedutil from '../../feed/util' -import { postToThreadgateUri } from '../../feed/util' +import { Notification } from '../../db/tables/notification' +import { Database } from '../../db' +import { countAll, excluded } from '../../db/util' +import { + getAncestorsAndSelfQb, + getDescendentsQb, + invalidReplyRoot as checkInvalidReplyRoot, + violatesThreadGate as checkViolatesThreadGate, + postToThreadgateUri, +} from '../../util' +import { BackgroundQueue } from '../../background' type Notif = Insertable type Post = Selectable @@ -392,11 +395,10 @@ const updateAggregates = async (db: DatabaseSchema, postIdx: IndexedPost) => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, @@ -427,9 +429,9 @@ async function validateReply( const replyRefs = await getReplyRefs(db, reply) // check reply const invalidReplyRoot = - !replyRefs.parent || feedutil.invalidReplyRoot(reply, replyRefs.parent) + !replyRefs.parent || checkInvalidReplyRoot(reply, replyRefs.parent) // check interaction - const violatesThreadGate = await feedutil.violatesThreadGate( + const violatesThreadGate = await checkViolatesThreadGate( db, creator, new AtUri(reply.root.uri).hostname, diff --git a/packages/bsky/src/services/indexing/plugins/profile.ts b/packages/bsky/src/data-plane/server/indexing/plugins/profile.ts similarity index 75% rename from packages/bsky/src/services/indexing/plugins/profile.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/profile.ts index ea0c8f07f98..18c9b54bbb9 100644 --- a/packages/bsky/src/services/indexing/plugins/profile.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/profile.ts @@ -1,12 +1,11 @@ import { AtUri } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as Profile from '../../../lexicon/types/app/bsky/actor/profile' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Profile from '../../../../lexicon/types/app/bsky/actor/profile' +import * as lex from '../../../../lexicon/lexicons' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyActorProfile type IndexedProfile = DatabaseSchemaType['profile'] @@ -64,11 +63,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/repost.ts b/packages/bsky/src/data-plane/server/indexing/plugins/repost.ts similarity index 85% rename from packages/bsky/src/services/indexing/plugins/repost.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/repost.ts index ea8d517dc52..ec2e7754fb0 100644 --- a/packages/bsky/src/services/indexing/plugins/repost.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/repost.ts @@ -1,14 +1,13 @@ import { Selectable } from 'kysely' import { CID } from 'multiformats/cid' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as Repost from '../../../lexicon/types/app/bsky/feed/repost' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Repost from '../../../../lexicon/types/app/bsky/feed/repost' +import * as lex from '../../../../lexicon/lexicons' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { countAll, excluded } from '../../../db/util' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { countAll, excluded } from '../../db/util' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyFeedRepost type IndexedRepost = Selectable @@ -134,11 +133,10 @@ const updateAggregates = async (db: DatabaseSchema, repost: IndexedRepost) => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/thread-gate.ts b/packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts similarity index 79% rename from packages/bsky/src/services/indexing/plugins/thread-gate.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts index 9a58547f2da..0402fe8289f 100644 --- a/packages/bsky/src/services/indexing/plugins/thread-gate.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts @@ -1,13 +1,12 @@ import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' import { CID } from 'multiformats/cid' -import * as Threadgate from '../../../lexicon/types/app/bsky/feed/threadgate' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Threadgate from '../../../../lexicon/types/app/bsky/feed/threadgate' +import * as lex from '../../../../lexicon/lexicons' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { Database } from '../../db' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyFeedThreadgate type IndexedGate = DatabaseSchemaType['thread_gate'] @@ -77,11 +76,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/processor.ts b/packages/bsky/src/data-plane/server/indexing/processor.ts similarity index 80% rename from packages/bsky/src/services/indexing/processor.ts rename to packages/bsky/src/data-plane/server/indexing/processor.ts index 0dad405b9ef..77a8fbdf09f 100644 --- a/packages/bsky/src/services/indexing/processor.ts +++ b/packages/bsky/src/data-plane/server/indexing/processor.ts @@ -1,15 +1,13 @@ import { Insertable } from 'kysely' import { CID } from 'multiformats/cid' import { AtUri } from '@atproto/syntax' -import { jsonStringToLex, stringifyLex } from '@atproto/lexicon' -import DatabaseSchema from '../../db/database-schema' -import { lexicons } from '../../lexicon/lexicons' -import { Notification } from '../../db/tables/notification' import { chunkArray } from '@atproto/common' -import { PrimaryDatabase } from '../../db' -import { BackgroundQueue } from '../../background' -import { NotificationServer } from '../../notifications' -import { dbLogger } from '../../logger' +import { jsonStringToLex, stringifyLex } from '@atproto/lexicon' +import { lexicons } from '../../../lexicon/lexicons' +import { Database } from '../db' +import DatabaseSchema from '../db/database-schema' +import { Notification } from '../db/tables/notification' +import { BackgroundQueue } from '../background' // @NOTE re: insertions and deletions. Due to how record updates are handled, // (insertFn) should have the same effect as (insertFn -> deleteFn -> insertFn). @@ -42,9 +40,8 @@ export class RecordProcessor { collection: string db: DatabaseSchema constructor( - private appDb: PrimaryDatabase, - private backgroundQueue: BackgroundQueue, - private notifServer: NotificationServer | undefined, + private appDb: Database, + private background: BackgroundQueue, private params: RecordProcessorParams, ) { this.db = appDb.db @@ -228,8 +225,7 @@ export class RecordProcessor { async handleNotifs(op: { deleted?: S; inserted?: S }) { let notifs: Notif[] = [] - const runOnCommit: ((db: PrimaryDatabase) => Promise)[] = [] - const sendOnCommit: (() => Promise)[] = [] + const runOnCommit: ((db: Database) => Promise)[] = [] if (op.deleted) { const forDelete = this.params.notifsForDelete( op.deleted, @@ -253,37 +249,10 @@ export class RecordProcessor { runOnCommit.push(async (db) => { await db.db.insertInto('notification').values(chunk).execute() }) - if (this.notifServer) { - const notifServer = this.notifServer - sendOnCommit.push(async () => { - try { - const preparedNotifs = await notifServer.prepareNotifications(chunk) - await notifServer.processNotifications(preparedNotifs) - } catch (error) { - dbLogger.error({ error }, 'error sending push notifications') - } - }) - } } - if (runOnCommit.length) { - // Need to ensure notif deletion always happens before creation, otherwise delete may clobber in a race. - this.appDb.onCommit(() => { - this.backgroundQueue.add(async (db) => { - for (const fn of runOnCommit) { - await fn(db) - } - }) - }) - } - if (sendOnCommit.length) { - // Need to ensure notif deletion always happens before creation, otherwise delete may clobber in a race. - this.appDb.onCommit(() => { - this.backgroundQueue.add(async () => { - for (const fn of sendOnCommit) { - await fn() - } - }) - }) + // Need to ensure notif deletion always happens before creation, otherwise delete may clobber in a race. + for (const fn of runOnCommit) { + await fn(this.appDb) // these could be backgrounded } } @@ -291,7 +260,7 @@ export class RecordProcessor { const { updateAggregates } = this.params if (!updateAggregates) return this.appDb.onCommit(() => { - this.backgroundQueue.add((db) => updateAggregates(db.db, indexed)) + this.background.add((db) => updateAggregates(db.db, indexed)) }) } } diff --git a/packages/bsky/src/data-plane/server/routes/blocks.ts b/packages/bsky/src/data-plane/server/routes/blocks.ts new file mode 100644 index 00000000000..54ec900b280 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/blocks.ts @@ -0,0 +1,121 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getBidirectionalBlock(req) { + const { actorDid, targetDid } = req + const res = await db.db + .selectFrom('actor_block') + .where((qb) => + qb + .where('actor_block.creator', '=', actorDid) + .where('actor_block.subjectDid', '=', targetDid), + ) + .orWhere((qb) => + qb + .where('actor_block.creator', '=', targetDid) + .where('actor_block.subjectDid', '=', actorDid), + ) + .limit(1) + .selectAll() + .executeTakeFirst() + + return { + blockUri: res?.uri, + } + }, + + async getBlocks(req) { + const { actorDid, cursor, limit } = req + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('actor_block') + .where('actor_block.creator', '=', actorDid) + .selectAll() + + const keyset = new TimeCidKeyset( + ref('actor_block.sortAt'), + ref('actor_block.cid'), + ) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const blocks = await builder.execute() + return { + blockUris: blocks.map((b) => b.uri), + cursor: keyset.packFromResult(blocks), + } + }, + + async getBidirectionalBlockViaList(req) { + const { actorDid, targetDid } = req + const res = await db.db + .selectFrom('list_block') + .innerJoin('list_item', 'list_item.listUri', 'list_block.subjectUri') + .where((qb) => + qb + .where('list_block.creator', '=', actorDid) + .where('list_item.subjectDid', '=', targetDid), + ) + .orWhere((qb) => + qb + .where('list_block.creator', '=', targetDid) + .where('list_item.subjectDid', '=', actorDid), + ) + .limit(1) + .selectAll('list_block') + .executeTakeFirst() + + return { + listUri: res?.subjectUri, + } + }, + + async getBlocklistSubscription(req) { + const { actorDid, listUri } = req + const res = await db.db + .selectFrom('list_block') + .where('creator', '=', actorDid) + .where('subjectUri', '=', listUri) + .selectAll() + .limit(1) + .executeTakeFirst() + return { + listblockUri: res?.uri, + } + }, + + async getBlocklistSubscriptions(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('list') + .whereExists( + db.db + .selectFrom('list_block') + .where('list_block.creator', '=', actorDid) + .whereRef('list_block.subjectUri', '=', ref('list.uri')) + .selectAll(), + ) + .selectAll('list') + + const keyset = new TimeCidKeyset(ref('list.createdAt'), ref('list.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + const lists = await builder.execute() + + return { + listUris: lists.map((l) => l.uri), + cursor: keyset.packFromResult(lists), + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/feed-gens.ts b/packages/bsky/src/data-plane/server/routes/feed-gens.ts new file mode 100644 index 00000000000..129229f923f --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/feed-gens.ts @@ -0,0 +1,70 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getActorFeeds(req) { + const { actorDid, limit, cursor } = req + + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('feed_generator') + .selectAll() + .where('feed_generator.creator', '=', actorDid) + + const keyset = new TimeCidKeyset( + ref('feed_generator.createdAt'), + ref('feed_generator.cid'), + ) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + const feeds = await builder.execute() + + return { + uris: feeds.map((f) => f.uri), + cursor: keyset.packFromResult(feeds), + } + }, + + async getSuggestedFeeds(req) { + const feeds = await db.db + .selectFrom('suggested_feed') + .orderBy('suggested_feed.order', 'asc') + .if(!!req.cursor, (q) => q.where('order', '>', parseInt(req.cursor, 10))) + .limit(req.limit || 50) + .selectAll() + .execute() + return { + uris: feeds.map((f) => f.uri), + cursor: feeds.at(-1)?.order.toString(), + } + }, + + async searchFeedGenerators(req) { + const { ref } = db.db.dynamic + const limit = req.limit + const query = req.query.trim() + let builder = db.db + .selectFrom('feed_generator') + .if(!!query, (q) => q.where('displayName', 'ilike', `%${query}%`)) + .selectAll() + const keyset = new TimeCidKeyset( + ref('feed_generator.createdAt'), + ref('feed_generator.cid'), + ) + builder = paginate(builder, { limit, keyset }) + const feeds = await builder.execute() + return { + uris: feeds.map((f) => f.uri), + cursor: keyset.packFromResult(feeds), + } + }, + + async getFeedGeneratorStatus() { + throw new Error('unimplemented') + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/feeds.ts b/packages/bsky/src/data-plane/server/routes/feeds.ts new file mode 100644 index 00000000000..3cb56d57e3f --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/feeds.ts @@ -0,0 +1,148 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' +import { FeedType } from '../../../proto/bsky_pb' + +export default (db: Database): Partial> => ({ + async getAuthorFeed(req) { + const { actorDid, limit, cursor, feedType } = req + const { ref } = db.db.dynamic + + // defaults to posts, reposts, and replies + let builder = db.db + .selectFrom('feed_item') + .innerJoin('post', 'post.uri', 'feed_item.postUri') + .selectAll('feed_item') + .where('originatorDid', '=', actorDid) + + if (feedType === FeedType.POSTS_WITH_MEDIA) { + builder = builder + // only your own posts + .where('type', '=', 'post') + // only posts with media + .whereExists((qb) => + qb + .selectFrom('post_embed_image') + .select('post_embed_image.postUri') + .whereRef('post_embed_image.postUri', '=', 'feed_item.postUri'), + ) + } else if (feedType === FeedType.POSTS_NO_REPLIES) { + builder = builder.where((qb) => + qb.where('post.replyParent', 'is', null).orWhere('type', '=', 'repost'), + ) + } else if (feedType === FeedType.POSTS_AND_AUTHOR_THREADS) { + builder = builder.where((qb) => + qb + .where('type', '=', 'repost') + .orWhere('post.replyParent', 'is', null) + .orWhere('post.replyRoot', 'like', `at://${actorDid}/%`), + ) + } + + const keyset = new TimeCidKeyset( + ref('feed_item.sortAt'), + ref('feed_item.cid'), + ) + + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const feedItems = await builder.execute() + + return { + items: feedItems.map(feedItemFromRow), + cursor: keyset.packFromResult(feedItems), + } + }, + + async getTimeline(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + + const keyset = new TimeCidKeyset( + ref('feed_item.sortAt'), + ref('feed_item.cid'), + ) + + let followQb = db.db + .selectFrom('feed_item') + .innerJoin('follow', 'follow.subjectDid', 'feed_item.originatorDid') + .where('follow.creator', '=', actorDid) + .selectAll('feed_item') + + followQb = paginate(followQb, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + let selfQb = db.db + .selectFrom('feed_item') + .where('feed_item.originatorDid', '=', actorDid) + .selectAll('feed_item') + + selfQb = paginate(selfQb, { + limit: Math.min(limit, 10), + cursor, + keyset, + tryIndex: true, + }) + + const [followRes, selfRes] = await Promise.all([ + followQb.execute(), + selfQb.execute(), + ]) + + const feedItems = [...followRes, ...selfRes] + .sort((a, b) => { + if (a.sortAt > b.sortAt) return -1 + if (a.sortAt < b.sortAt) return 1 + return a.cid > b.cid ? -1 : 1 + }) + .slice(0, limit) + + return { + items: feedItems.map(feedItemFromRow), + cursor: keyset.packFromResult(feedItems), + } + }, + + async getListFeed(req) { + const { listUri, cursor, limit } = req + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('post') + .selectAll('post') + .innerJoin('list_item', 'list_item.subjectDid', 'post.creator') + .where('list_item.listUri', '=', listUri) + + const keyset = new TimeCidKeyset(ref('post.sortAt'), ref('post.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + tryIndex: true, + }) + const feedItems = await builder.execute() + + return { + items: feedItems.map((item) => ({ uri: item.uri, cid: item.cid })), + cursor: keyset.packFromResult(feedItems), + } + }, +}) + +// @NOTE does not support additional fields in the protos specific to author feeds +// and timelines. at the time of writing, hydration/view implementations do not rely on them. +const feedItemFromRow = (row: { postUri: string; uri: string }) => { + return { + uri: row.postUri, + repost: row.uri === row.postUri ? undefined : row.uri, + } +} diff --git a/packages/bsky/src/data-plane/server/routes/follows.ts b/packages/bsky/src/data-plane/server/routes/follows.ts new file mode 100644 index 00000000000..1380fa281d6 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/follows.ts @@ -0,0 +1,95 @@ +import { keyBy } from '@atproto/common' +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getActorFollowsActors(req) { + const { actorDid, targetDids } = req + if (targetDids.length < 1) { + return { uris: [] } + } + const res = await db.db + .selectFrom('follow') + .where('follow.creator', '=', actorDid) + .where('follow.subjectDid', 'in', targetDids) + .selectAll() + .execute() + const bySubject = keyBy(res, 'subjectDid') + const uris = targetDids.map((did) => bySubject[did]?.uri ?? '') + return { + uris, + } + }, + async getFollowers(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + let followersReq = db.db + .selectFrom('follow') + .where('follow.subjectDid', '=', actorDid) + .innerJoin('actor as creator', 'creator.did', 'follow.creator') + .selectAll('creator') + .select([ + 'follow.uri as uri', + 'follow.cid as cid', + 'follow.creator as creatorDid', + 'follow.subjectDid as subjectDid', + 'follow.sortAt as sortAt', + ]) + + const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) + followersReq = paginate(followersReq, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + const followers = await followersReq.execute() + return { + followers: followers.map((f) => ({ + uri: f.uri, + actorDid: f.creatorDid, + subjectDid: f.subjectDid, + })), + cursor: keyset.packFromResult(followers), + } + }, + async getFollows(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + + let followsReq = db.db + .selectFrom('follow') + .where('follow.creator', '=', actorDid) + .innerJoin('actor as subject', 'subject.did', 'follow.subjectDid') + .selectAll('subject') + .select([ + 'follow.uri as uri', + 'follow.cid as cid', + 'follow.creator as creatorDid', + 'follow.subjectDid as subjectDid', + 'follow.sortAt as sortAt', + ]) + + const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) + followsReq = paginate(followsReq, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + const follows = await followsReq.execute() + + return { + follows: follows.map((f) => ({ + uri: f.uri, + actorDid: f.creatorDid, + subjectDid: f.subjectDid, + })), + cursor: keyset.packFromResult(follows), + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/identity.ts b/packages/bsky/src/data-plane/server/routes/identity.ts new file mode 100644 index 00000000000..fb8dffc096c --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/identity.ts @@ -0,0 +1,59 @@ +import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { DidDocument, IdResolver, getDid, getHandle } from '@atproto/identity' +import { Timestamp } from '@bufbuild/protobuf' + +export default ( + _db: Database, + idResolver: IdResolver, +): Partial> => ({ + async getIdentityByDid(req) { + const doc = await idResolver.did.resolve(req.did) + if (!doc) { + throw new ConnectError('identity not found', Code.NotFound) + } + return getResultFromDoc(doc) + }, + + async getIdentityByHandle(req) { + const did = await idResolver.handle.resolve(req.handle) + if (!did) { + throw new ConnectError('identity not found', Code.NotFound) + } + const doc = await idResolver.did.resolve(did) + if (!doc || did !== getDid(doc)) { + throw new ConnectError('identity not found', Code.NotFound) + } + return getResultFromDoc(doc) + }, +}) + +const getResultFromDoc = (doc: DidDocument) => { + const keys: Record = {} + doc.verificationMethod?.forEach((method) => { + const id = method.id.split('#').at(1) + if (!id) return + keys[id] = { + Type: method.type, + PublicKeyMultibase: method.publicKeyMultibase || '', + } + }) + const services: Record = {} + doc.service?.forEach((service) => { + const id = service.id.split('#').at(1) + if (!id) return + if (typeof service.serviceEndpoint !== 'string') return + services[id] = { + Type: service.type, + URL: service.serviceEndpoint, + } + }) + return { + did: getDid(doc), + handle: getHandle(doc), + keys: Buffer.from(JSON.stringify(keys)), + services: Buffer.from(JSON.stringify(services)), + updated: Timestamp.fromDate(new Date()), + } +} diff --git a/packages/bsky/src/data-plane/server/routes/index.ts b/packages/bsky/src/data-plane/server/routes/index.ts new file mode 100644 index 00000000000..6169a48a28d --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/index.ts @@ -0,0 +1,55 @@ +import { ConnectRouter } from '@connectrpc/connect' +import { IdResolver } from '@atproto/identity' +import { Service } from '../../../proto/bsky_connect' +import blocks from './blocks' +import feedGens from './feed-gens' +import feeds from './feeds' +import follows from './follows' +import identity from './identity' +import interactions from './interactions' +import labels from './labels' +import likes from './likes' +import lists from './lists' +import moderation from './moderation' +import mutes from './mutes' +import notifs from './notifs' +import posts from './posts' +import profile from './profile' +import records from './records' +import relationships from './relationships' +import reposts from './reposts' +import search from './search' +import suggestions from './suggestions' +import sync from './sync' +import threads from './threads' +import { Database } from '../db' + +export default (db: Database, idResolver: IdResolver) => + (router: ConnectRouter) => + router.service(Service, { + ...blocks(db), + ...feedGens(db), + ...feeds(db), + ...follows(db), + ...identity(db, idResolver), + ...interactions(db), + ...labels(db), + ...likes(db), + ...lists(db), + ...moderation(db), + ...mutes(db), + ...notifs(db), + ...posts(db), + ...profile(db), + ...records(db), + ...relationships(db), + ...reposts(db), + ...search(db), + ...suggestions(db), + ...sync(db), + ...threads(db), + + async ping() { + return {} + }, + }) diff --git a/packages/bsky/src/data-plane/server/routes/interactions.ts b/packages/bsky/src/data-plane/server/routes/interactions.ts new file mode 100644 index 00000000000..73e46372a55 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/interactions.ts @@ -0,0 +1,40 @@ +import { keyBy } from '@atproto/common' +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' + +export default (db: Database): Partial> => ({ + async getInteractionCounts(req) { + const uris = req.refs.map((ref) => ref.uri) + if (uris.length === 0) { + return { likes: [], replies: [], reposts: [] } + } + const res = await db.db + .selectFrom('post_agg') + .where('uri', 'in', uris) + .selectAll() + .execute() + const byUri = keyBy(res, 'uri') + return { + likes: uris.map((uri) => byUri[uri]?.likeCount ?? 0), + replies: uris.map((uri) => byUri[uri]?.replyCount ?? 0), + reposts: uris.map((uri) => byUri[uri]?.repostCount ?? 0), + } + }, + async getCountsForUsers(req) { + if (req.dids.length === 0) { + return { followers: [], following: [], posts: [] } + } + const res = await db.db + .selectFrom('profile_agg') + .selectAll() + .where('did', 'in', req.dids) + .execute() + const byDid = keyBy(res, 'did') + return { + followers: req.dids.map((uri) => byDid[uri]?.followersCount ?? 0), + following: req.dids.map((uri) => byDid[uri]?.followsCount ?? 0), + posts: req.dids.map((uri) => byDid[uri]?.postsCount ?? 0), + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/labels.ts b/packages/bsky/src/data-plane/server/routes/labels.ts new file mode 100644 index 00000000000..dd58387f579 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/labels.ts @@ -0,0 +1,28 @@ +import * as ui8 from 'uint8arrays' +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' + +export default (db: Database): Partial> => ({ + async getLabels(req) { + const { subjects, issuers } = req + if (subjects.length === 0 || issuers.length === 0) { + return { records: [] } + } + const res = await db.db + .selectFrom('label') + .where('uri', 'in', subjects) + .where('src', 'in', issuers) + .selectAll() + .execute() + + const labels = res.map((l) => { + const formatted = { + ...l, + cid: l.cid === '' ? undefined : l.cid, + } + return ui8.fromString(JSON.stringify(formatted), 'utf8') + }) + return { labels } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/likes.ts b/packages/bsky/src/data-plane/server/routes/likes.ts new file mode 100644 index 00000000000..7f7ace202b7 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/likes.ts @@ -0,0 +1,86 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' +import { keyBy } from '@atproto/common' + +export default (db: Database): Partial> => ({ + async getLikesBySubject(req) { + const { subject, cursor, limit } = req + const { ref } = db.db.dynamic + + if (!subject?.uri) { + return { uris: [] } + } + + // @NOTE ignoring subject.cid + let builder = db.db + .selectFrom('like') + .where('like.subject', '=', subject?.uri) + .selectAll('like') + + const keyset = new TimeCidKeyset(ref('like.sortAt'), ref('like.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const likes = await builder.execute() + + return { + uris: likes.map((l) => l.uri), + cursor: keyset.packFromResult(likes), + } + }, + + async getLikesByActorAndSubjects(req) { + const { actorDid, refs } = req + if (refs.length === 0) { + return { uris: [] } + } + // @NOTE ignoring ref.cid + const res = await db.db + .selectFrom('like') + .where('creator', '=', actorDid) + .where( + 'subject', + 'in', + refs.map(({ uri }) => uri), + ) + .selectAll() + .execute() + const bySubject = keyBy(res, 'subject') + // @TODO handling undefineds properly, or do we need to turn them into empty strings? + const uris = refs.map(({ uri }) => bySubject[uri]?.uri) + return { uris } + }, + + async getActorLikes(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('like') + .where('like.creator', '=', actorDid) + .selectAll() + + const keyset = new TimeCidKeyset(ref('like.sortAt'), ref('like.cid')) + + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const likes = await builder.execute() + + return { + likes: likes.map((l) => ({ + uri: l.uri, + subject: l.subject, + })), + cursor: keyset.packFromResult(likes), + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/lists.ts b/packages/bsky/src/data-plane/server/routes/lists.ts new file mode 100644 index 00000000000..d757b1d39e4 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/lists.ts @@ -0,0 +1,88 @@ +import { keyBy } from '@atproto/common' +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { countAll } from '../db/util' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getActorLists(req) { + const { actorDid, cursor, limit } = req + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('list') + .where('creator', '=', actorDid) + .selectAll() + const keyset = new TimeCidKeyset(ref('list.sortAt'), ref('list.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + tryIndex: true, + }) + const lists = await builder.execute() + return { + listUris: lists.map((item) => item.uri), + cursor: keyset.packFromResult(lists), + } + }, + + async getListMembers(req) { + const { listUri, cursor, limit } = req + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('list_item') + .where('listUri', '=', listUri) + .selectAll() + + const keyset = new TimeCidKeyset( + ref('list_item.sortAt'), + ref('list_item.cid'), + ) + + builder = paginate(builder, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + const listItems = await builder.execute() + return { + listitems: listItems.map((item) => ({ + uri: item.uri, + did: item.subjectDid, + })), + cursor: keyset.packFromResult(listItems), + } + }, + + async getListMembership(req) { + const { actorDid, listUris } = req + if (listUris.length === 0) { + return { listitemUris: [] } + } + const res = await db.db + .selectFrom('list_item') + .where('subjectDid', '=', actorDid) + .where('listUri', 'in', listUris) + .selectAll() + .execute() + const byListUri = keyBy(res, 'listUri') + const listitemUris = listUris.map((uri) => byListUri[uri]?.uri ?? '') + return { + listitemUris, + } + }, + + async getListCount(req) { + const res = await db.db + .selectFrom('list_item') + .select(countAll.as('count')) + .where('list_item.listUri', '=', req.listUri) + .executeTakeFirst() + return { + count: res?.count, + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/moderation.ts b/packages/bsky/src/data-plane/server/routes/moderation.ts new file mode 100644 index 00000000000..d59360847fe --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/moderation.ts @@ -0,0 +1,102 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' + +export default (db: Database): Partial> => ({ + async getActorTakedown(req) { + const { did } = req + const res = await db.db + .selectFrom('actor') + .where('did', '=', did) + .select('takedownRef') + .executeTakeFirst() + return { + takenDown: !!res?.takedownRef, + takedownRef: res?.takedownRef || undefined, + } + }, + + async getBlobTakedown(req) { + const { did, cid } = req + const res = await db.db + .selectFrom('blob_takedown') + .where('did', '=', did) + .where('cid', '=', cid) + .select('takedownRef') + .executeTakeFirst() + return { + takenDown: !!res, + takedownRef: res?.takedownRef || undefined, + } + }, + + async getRecordTakedown(req) { + const { recordUri } = req + const res = await db.db + .selectFrom('record') + .where('uri', '=', recordUri) + .select('takedownRef') + .executeTakeFirst() + return { + takenDown: !!res?.takedownRef, + takedownRef: res?.takedownRef || undefined, + } + }, + + async takedownActor(req) { + const { did, ref } = req + await db.db + .updateTable('actor') + .set({ takedownRef: ref || 'TAKEDOWN' }) + .where('did', '=', did) + .execute() + }, + + async takedownBlob(req) { + const { did, cid, ref } = req + await db.db + .insertInto('blob_takedown') + .values({ + did, + cid, + takedownRef: ref || 'TAKEDOWN', + }) + .execute() + }, + + async takedownRecord(req) { + const { recordUri, ref } = req + await db.db + .updateTable('record') + .set({ takedownRef: ref || 'TAKEDOWN' }) + .where('uri', '=', recordUri) + .execute() + }, + + async untakedownActor(req) { + const { did } = req + await db.db + .updateTable('actor') + .set({ takedownRef: null }) + .where('did', '=', did) + .execute() + }, + + async untakedownBlob(req) { + const { did, cid } = req + await db.db + .deleteFrom('blob_takedown') + .where('did', '=', did) + .where('cid', '=', cid) + .executeTakeFirst() + }, + + async untakedownRecord(req) { + const { recordUri } = req + await db.db + .updateTable('record') + .set({ takedownRef: null }) + .where('uri', '=', recordUri) + .execute() + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/mutes.ts b/packages/bsky/src/data-plane/server/routes/mutes.ts new file mode 100644 index 00000000000..387e0f4d904 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/mutes.ts @@ -0,0 +1,172 @@ +import assert from 'assert' +import { ServiceImpl } from '@connectrpc/connect' +import { AtUri } from '@atproto/syntax' +import { ids } from '../../../lexicon/lexicons' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { CreatedAtDidKeyset, TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getActorMutesActor(req) { + const { actorDid, targetDid } = req + const res = await db.db + .selectFrom('mute') + .selectAll() + .where('mutedByDid', '=', actorDid) + .where('subjectDid', '=', targetDid) + .executeTakeFirst() + return { + muted: !!res, + } + }, + + async getMutes(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('mute') + .innerJoin('actor', 'actor.did', 'mute.subjectDid') + .where('mute.mutedByDid', '=', actorDid) + .selectAll('actor') + .select('mute.createdAt as createdAt') + + const keyset = new CreatedAtDidKeyset( + ref('mute.createdAt'), + ref('mute.subjectDid'), + ) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const mutes = await builder.execute() + + return { + dids: mutes.map((m) => m.did), + cursor: keyset.packFromResult(mutes), + } + }, + + async getActorMutesActorViaList(req) { + const { actorDid, targetDid } = req + const res = await db.db + .selectFrom('list_mute') + .innerJoin('list_item', 'list_item.listUri', 'list_mute.listUri') + .where('list_mute.mutedByDid', '=', actorDid) + .where('list_item.subjectDid', '=', targetDid) + .select('list_mute.listUri') + .limit(1) + .executeTakeFirst() + return { + listUri: res?.listUri, + } + }, + + async getMutelistSubscription(req) { + const { actorDid, listUri } = req + const res = await db.db + .selectFrom('list_mute') + .where('mutedByDid', '=', actorDid) + .where('listUri', '=', listUri) + .selectAll() + .limit(1) + .executeTakeFirst() + return { + subscribed: !!res, + } + }, + + async getMutelistSubscriptions(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('list') + .whereExists( + db.db + .selectFrom('list_mute') + .where('list_mute.mutedByDid', '=', actorDid) + .whereRef('list_mute.listUri', '=', ref('list.uri')) + .selectAll(), + ) + .selectAll('list') + + const keyset = new TimeCidKeyset(ref('list.createdAt'), ref('list.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + const lists = await builder.execute() + + return { + listUris: lists.map((l) => l.uri), + cursor: keyset.packFromResult(lists), + } + }, + + async createActorMute(req) { + const { actorDid, subjectDid } = req + assert(actorDid !== subjectDid, 'cannot mute yourself') // @TODO pass message through in http error + await db.db + .insertInto('mute') + .values({ + subjectDid, + mutedByDid: actorDid, + createdAt: new Date().toISOString(), + }) + .onConflict((oc) => oc.doNothing()) + .execute() + }, + + async deleteActorMute(req) { + const { actorDid, subjectDid } = req + assert(actorDid !== subjectDid, 'cannot mute yourself') + await db.db + .deleteFrom('mute') + .where('subjectDid', '=', subjectDid) + .where('mutedByDid', '=', actorDid) + .execute() + }, + + async clearActorMutes(req) { + const { actorDid } = req + await db.db.deleteFrom('mute').where('mutedByDid', '=', actorDid).execute() + }, + + async createActorMutelistSubscription(req) { + const { actorDid, subjectUri } = req + assert(isListUri(subjectUri), 'must mute a list') + await db.db + .insertInto('list_mute') + .values({ + listUri: subjectUri, + mutedByDid: actorDid, + createdAt: new Date().toISOString(), + }) + .onConflict((oc) => oc.doNothing()) + .execute() + }, + + async deleteActorMutelistSubscription(req) { + const { actorDid, subjectUri } = req + assert(isListUri(subjectUri), 'must mute a list') + await db.db + .deleteFrom('list_mute') + .where('listUri', '=', subjectUri) + .where('mutedByDid', '=', actorDid) + .execute() + }, + + async clearActorMutelistSubscriptions(req) { + const { actorDid } = req + await db.db + .deleteFrom('list_mute') + .where('mutedByDid', '=', actorDid) + .execute() + }, +}) + +const isListUri = (uri: string) => + new AtUri(uri).collection === ids.AppBskyGraphList diff --git a/packages/bsky/src/data-plane/server/routes/notifs.ts b/packages/bsky/src/data-plane/server/routes/notifs.ts new file mode 100644 index 00000000000..d5289806128 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/notifs.ts @@ -0,0 +1,115 @@ +import { sql } from 'kysely' +import { ServiceImpl } from '@connectrpc/connect' +import { Timestamp } from '@bufbuild/protobuf' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { countAll, excluded, notSoftDeletedClause } from '../db/util' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getNotifications(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('notification as notif') + .where('notif.did', '=', actorDid) + .where((clause) => + clause + .where('reasonSubject', 'is', null) + .orWhereExists( + db.db + .selectFrom('record as subject') + .selectAll() + .whereRef('subject.uri', '=', ref('notif.reasonSubject')), + ), + ) + .select([ + 'notif.author as authorDid', + 'notif.recordUri as uri', + 'notif.recordCid as cid', + 'notif.reason as reason', + 'notif.reasonSubject as reasonSubject', + 'notif.sortAt as sortAt', + ]) + + const keyset = new TimeCidKeyset( + ref('notif.sortAt'), + ref('notif.recordCid'), + ) + builder = paginate(builder, { + cursor, + limit, + keyset, + tryIndex: true, + }) + + const notifsRes = await builder.execute() + const notifications = notifsRes.map((notif) => ({ + recipientDid: actorDid, + uri: notif.uri, + reason: notif.reason, + reasonSubject: notif.reasonSubject ?? undefined, + timestamp: Timestamp.fromDate(new Date(notif.sortAt)), + })) + return { + notifications, + cursor: keyset.packFromResult(notifsRes), + } + }, + + async getNotificationSeen(req) { + const res = await db.db + .selectFrom('actor_state') + .where('did', '=', req.actorDid) + .selectAll() + .executeTakeFirst() + if (!res) { + return {} + } + return { + timestamp: Timestamp.fromDate(new Date(res.lastSeenNotifs)), + } + }, + + async getUnreadNotificationCount(req) { + const { actorDid } = req + const { ref } = db.db.dynamic + const result = await db.db + .selectFrom('notification') + .select(countAll.as('count')) + .innerJoin('actor', 'actor.did', 'notification.did') + .leftJoin('actor_state', 'actor_state.did', 'actor.did') + .innerJoin('record', 'record.uri', 'notification.recordUri') + .where(notSoftDeletedClause(ref('record'))) + .where(notSoftDeletedClause(ref('actor'))) + // Ensure to hit notification_did_sortat_idx, handling case where lastSeenNotifs is null. + .where('notification.did', '=', actorDid) + .where( + 'notification.sortAt', + '>', + sql`coalesce(${ref('actor_state.lastSeenNotifs')}, ${''})`, + ) + .executeTakeFirst() + + return { + count: result?.count, + } + }, + + async updateNotificationSeen(req) { + const { actorDid, timestamp } = req + if (!timestamp) { + return + } + const lastSeenNotifs = timestamp.toDate().toISOString() + await db.db + .insertInto('actor_state') + .values({ did: actorDid, lastSeenNotifs }) + .onConflict((oc) => + oc.column('did').doUpdateSet({ + lastSeenNotifs: excluded(db.db, 'lastSeenNotifs'), + }), + ) + .executeTakeFirst() + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/posts.ts b/packages/bsky/src/data-plane/server/routes/posts.ts new file mode 100644 index 00000000000..8f90a34b3cc --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/posts.ts @@ -0,0 +1,21 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { keyBy } from '@atproto/common' +import { Database } from '../db' + +export default (db: Database): Partial> => ({ + async getPostReplyCounts(req) { + const uris = req.refs.map((ref) => ref.uri) + if (uris.length === 0) { + return { counts: [] } + } + const res = await db.db + .selectFrom('post_agg') + .select(['uri', 'replyCount']) + .where('uri', 'in', uris) + .execute() + const byUri = keyBy(res, 'uri') + const counts = uris.map((uri) => byUri[uri]?.replyCount ?? 0) + return { counts } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/profile.ts b/packages/bsky/src/data-plane/server/routes/profile.ts new file mode 100644 index 00000000000..20768ed89aa --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/profile.ts @@ -0,0 +1,49 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { keyBy } from '@atproto/common' +import { getRecords } from './records' +import { Database } from '../db' + +export default (db: Database): Partial> => ({ + async getActors(req) { + const { dids } = req + if (dids.length === 0) { + return { actors: [] } + } + const profileUris = dids.map( + (did) => `at://${did}/app.bsky.actor.profile/self`, + ) + const [handlesRes, profiles] = await Promise.all([ + db.db.selectFrom('actor').where('did', 'in', dids).selectAll().execute(), + getRecords(db)({ uris: profileUris }), + ]) + const byDid = keyBy(handlesRes, 'did') + const actors = dids.map((did, i) => { + const row = byDid[did] + return { + exists: !!row, + handle: row?.handle ?? undefined, + profile: profiles.records[i], + takenDown: !!row?.takedownRef, + takedownRef: row?.takedownRef || undefined, + tombstonedAt: undefined, // in current implementation, tombstoned actors are deleted + } + }) + return { actors } + }, + + async getDidsByHandles(req) { + const { handles } = req + if (handles.length === 0) { + return { dids: [] } + } + const res = await db.db + .selectFrom('actor') + .where('handle', 'in', handles) + .selectAll() + .execute() + const byHandle = keyBy(res, 'handle') + const dids = handles.map((handle) => byHandle[handle]?.did ?? '') + return { dids } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/records.ts b/packages/bsky/src/data-plane/server/routes/records.ts new file mode 100644 index 00000000000..47670a24412 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/records.ts @@ -0,0 +1,95 @@ +import { keyBy } from '@atproto/common' +import { AtUri } from '@atproto/syntax' +import { Timestamp } from '@bufbuild/protobuf' +import { ServiceImpl } from '@connectrpc/connect' +import * as ui8 from 'uint8arrays' +import { ids } from '../../../lexicon/lexicons' +import { Service } from '../../../proto/bsky_connect' +import { PostRecordMeta, Record } from '../../../proto/bsky_pb' +import { Database } from '../db' + +export default (db: Database): Partial> => ({ + getBlockRecords: getRecords(db, ids.AppBskyGraphBlock), + getFeedGeneratorRecords: getRecords(db, ids.AppBskyFeedGenerator), + getFollowRecords: getRecords(db, ids.AppBskyGraphFollow), + getLikeRecords: getRecords(db, ids.AppBskyFeedLike), + getListBlockRecords: getRecords(db, ids.AppBskyGraphListblock), + getListItemRecords: getRecords(db, ids.AppBskyGraphListitem), + getListRecords: getRecords(db, ids.AppBskyGraphList), + getPostRecords: getPostRecords(db), + getProfileRecords: getRecords(db, ids.AppBskyActorProfile), + getRepostRecords: getRecords(db, ids.AppBskyFeedRepost), + getThreadGateRecords: getRecords(db, ids.AppBskyFeedThreadgate), +}) + +export const getRecords = + (db: Database, collection?: string) => + async (req: { uris: string[] }): Promise<{ records: Record[] }> => { + const validUris = collection + ? req.uris.filter((uri) => new AtUri(uri).collection === collection) + : req.uris + const res = validUris.length + ? await db.db + .selectFrom('record') + .selectAll() + .where('uri', 'in', validUris) + .execute() + : [] + const byUri = keyBy(res, 'uri') + const records: Record[] = req.uris.map((uri) => { + const row = byUri[uri] + const json = row ? row.json : JSON.stringify(null) + const createdAtRaw = new Date(JSON.parse(json)?.['createdAt']) + const createdAt = !isNaN(createdAtRaw.getTime()) + ? Timestamp.fromDate(createdAtRaw) + : undefined + const indexedAt = row?.indexedAt + ? Timestamp.fromDate(new Date(row?.indexedAt)) + : undefined + const recordBytes = ui8.fromString(json, 'utf8') + return new Record({ + record: recordBytes, + cid: row?.cid, + createdAt, + indexedAt, + sortedAt: compositeTime(createdAt, indexedAt), + takenDown: !!row?.takedownRef, + takedownRef: row?.takedownRef ?? undefined, + }) + }) + return { records } + } + +export const getPostRecords = (db: Database) => { + const getBaseRecords = getRecords(db, ids.AppBskyFeedPost) + return async (req: { + uris: string[] + }): Promise<{ records: Record[]; meta: PostRecordMeta[] }> => { + const [{ records }, details] = await Promise.all([ + getBaseRecords(req), + req.uris.length + ? await db.db + .selectFrom('post') + .where('uri', 'in', req.uris) + .select(['uri', 'violatesThreadGate']) + .execute() + : [], + ]) + const byKey = keyBy(details, 'uri') + const meta = req.uris.map((uri) => { + return new PostRecordMeta({ + violatesThreadGate: !!byKey[uri]?.violatesThreadGate, + }) + }) + return { records, meta } + } +} + +const compositeTime = ( + ts1: Timestamp | undefined, + ts2: Timestamp | undefined, +) => { + if (!ts1) return ts2 + if (!ts2) return ts1 + return ts1.toDate() < ts2.toDate() ? ts1 : ts2 +} diff --git a/packages/bsky/src/data-plane/server/routes/relationships.ts b/packages/bsky/src/data-plane/server/routes/relationships.ts new file mode 100644 index 00000000000..d6029e169e8 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/relationships.ts @@ -0,0 +1,148 @@ +import { sql } from 'kysely' +import { ServiceImpl } from '@connectrpc/connect' +import { keyBy } from '@atproto/common' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { valuesList } from '../db/util' + +export default (db: Database): Partial> => ({ + async getRelationships(req) { + const { actorDid, targetDids } = req + if (targetDids.length === 0) { + return { relationships: [] } + } + const { ref } = db.db.dynamic + const res = await db.db + .selectFrom('actor') + .where('did', 'in', targetDids) + .select([ + 'actor.did', + db.db + .selectFrom('mute') + .where('mute.mutedByDid', '=', actorDid) + .whereRef('mute.subjectDid', '=', ref('actor.did')) + .select(sql`${true}`.as('val')) + .as('muted'), + db.db + .selectFrom('list_item') + .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') + .where('list_mute.mutedByDid', '=', actorDid) + .whereRef('list_item.subjectDid', '=', ref('actor.did')) + .select('list_item.listUri') + .as('mutedByList'), + db.db + .selectFrom('actor_block') + .where('actor_block.creator', '=', actorDid) + .whereRef('actor_block.subjectDid', '=', ref('actor.did')) + .select('uri') + .as('blocking'), + db.db + .selectFrom('actor_block') + .where('actor_block.subjectDid', '=', actorDid) + .whereRef('actor_block.creator', '=', ref('actor.did')) + .select('uri') + .as('blockedBy'), + db.db + .selectFrom('list_item') + .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') + .where('list_block.creator', '=', actorDid) + .whereRef('list_item.subjectDid', '=', ref('actor.did')) + .select('list_item.listUri') + .as('blockingByList'), + db.db + .selectFrom('list_item') + .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') + .where('list_item.subjectDid', '=', actorDid) + .whereRef('list_block.creator', '=', ref('actor.did')) + .select('list_item.listUri') + .as('blockedByList'), + db.db + .selectFrom('follow') + .where('follow.creator', '=', actorDid) + .whereRef('follow.subjectDid', '=', ref('actor.did')) + .select('uri') + .as('following'), + db.db + .selectFrom('follow') + .where('follow.subjectDid', '=', actorDid) + .whereRef('follow.creator', '=', ref('actor.did')) + .select('uri') + .as('followedBy'), + ]) + .execute() + const byDid = keyBy(res, 'did') + const relationships = targetDids.map((did) => { + const row = byDid[did] ?? {} + return { + muted: row.muted ?? false, + mutedByList: row.mutedByList ?? '', + blockedBy: row.blockedBy ?? '', + blocking: row.blocking ?? '', + blockedByList: row.blockedByList ?? '', + blockingByList: row.blockingByList ?? '', + following: row.following ?? '', + followedBy: row.followedBy ?? '', + } + }) + return { relationships } + }, + + async getBlockExistence(req) { + const { pairs } = req + if (pairs.length === 0) { + return { exists: [] } + } + const { ref } = db.db.dynamic + const sourceRef = ref('pair.source') + const targetRef = ref('pair.target') + const values = valuesList(pairs.map((p) => sql`${p.a}, ${p.b}`)) + const res = await db.db + .selectFrom(values.as(sql`pair (source, target)`)) + .select([ + sql`${sourceRef}`.as('source'), + sql`${targetRef}`.as('target'), + ]) + .whereExists((qb) => + qb + .selectFrom('actor_block') + .whereRef('actor_block.creator', '=', sourceRef) + .whereRef('actor_block.subjectDid', '=', targetRef) + .select('uri'), + ) + .orWhereExists((qb) => + qb + .selectFrom('actor_block') + .whereRef('actor_block.creator', '=', targetRef) + .whereRef('actor_block.subjectDid', '=', sourceRef) + .select('uri'), + ) + .orWhereExists((qb) => + qb + .selectFrom('list_item') + .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') + .whereRef('list_block.creator', '=', sourceRef) + .whereRef('list_item.subjectDid', '=', targetRef) + .select('list_item.listUri'), + ) + .orWhereExists((qb) => + qb + .selectFrom('list_item') + .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') + .whereRef('list_block.creator', '=', targetRef) + .whereRef('list_item.subjectDid', '=', sourceRef) + .select('list_item.listUri'), + ) + .execute() + const existMap = res.reduce((acc, cur) => { + const key = [cur.source, cur.target].sort().join(',') + return acc.set(key, true) + }, new Map()) + const exists = pairs.map((pair) => { + const key = [pair.a, pair.b].sort().join(',') + return existMap.get(key) === true + }) + return { + exists, + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/reposts.ts b/packages/bsky/src/data-plane/server/routes/reposts.ts new file mode 100644 index 00000000000..9c6f72435c0 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/reposts.ts @@ -0,0 +1,77 @@ +import { keyBy } from '@atproto/common' +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getRepostsBySubject(req) { + const { subject, cursor, limit } = req + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('repost') + .where('repost.subject', '=', subject?.uri ?? '') + .selectAll('repost') + + const keyset = new TimeCidKeyset(ref('repost.sortAt'), ref('repost.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const reposts = await builder.execute() + + return { + uris: reposts.map((l) => l.uri), + cursor: keyset.packFromResult(reposts), + } + }, + + async getRepostsByActorAndSubjects(req) { + const { actorDid, refs } = req + if (refs.length === 0) { + return { uris: [] } + } + const res = await db.db + .selectFrom('repost') + .where('creator', '=', actorDid) + .where( + 'subject', + 'in', + refs.map(({ uri }) => uri), + ) + .selectAll() + .execute() + const bySubject = keyBy(res, 'subject') + // @TODO handling undefineds properly, or do we need to turn them into empty strings? + const uris = refs.map(({ uri }) => bySubject[uri]?.uri) + return { uris } + }, + + async getActorReposts(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('repost') + .where('repost.creator', '=', actorDid) + .selectAll() + + const keyset = new TimeCidKeyset(ref('repost.sortAt'), ref('repost.cid')) + + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const reposts = await builder.execute() + + return { + uris: reposts.map((l) => l.uri), + cursor: keyset.packFromResult(reposts), + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/search.ts b/packages/bsky/src/data-plane/server/routes/search.ts new file mode 100644 index 00000000000..7b3b12d8c29 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/search.ts @@ -0,0 +1,61 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { IndexedAtDidKeyset, TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + // @TODO actor search endpoints still fall back to search service + async searchActors(req) { + const { term, limit, cursor } = req + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('actor') + .where('actor.handle', 'like', `%${cleanQuery(term)}%`) + .selectAll() + + const keyset = new IndexedAtDidKeyset( + ref('actor.indexedAt'), + ref('actor.did'), + ) + builder = paginate(builder, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + const res = await builder.execute() + + return { + dids: res.map((row) => row.did), + cursor: keyset.packFromResult(res), + } + }, + + // @TODO post search endpoint still falls back to search service + async searchPosts(req) { + const { term, limit, cursor } = req + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('post') + .where('post.text', 'like', `%${term}%`) + .selectAll() + + const keyset = new TimeCidKeyset(ref('post.sortAt'), ref('post.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + const res = await builder.execute() + return { + uris: res.map((row) => row.uri), + cursor: keyset.packFromResult(res), + } + }, +}) + +// Remove leading @ in case a handle is input that way +const cleanQuery = (query: string) => query.trim().replace(/^@/g, '') diff --git a/packages/bsky/src/data-plane/server/routes/suggestions.ts b/packages/bsky/src/data-plane/server/routes/suggestions.ts new file mode 100644 index 00000000000..68b1dc54d5b --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/suggestions.ts @@ -0,0 +1,175 @@ +import { sql } from 'kysely' +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' + +export default (db: Database): Partial> => ({ + async getFollowSuggestions(req) { + const { actorDid, relativeToDid, cursor, limit } = req + if (relativeToDid) { + return getFollowSuggestionsRelativeTo(db, { + actorDid, + relativeToDid, + cursor: cursor || undefined, + limit: limit || undefined, + }) + } else { + return getFollowSuggestionsGlobal(db, { + actorDid, + cursor: cursor || undefined, + limit: limit || undefined, + }) + } + }, + + async getSuggestedEntities() { + const entities = await db.db + .selectFrom('tagged_suggestion') + .selectAll() + .execute() + return { + entities, + } + }, +}) + +const getFollowSuggestionsGlobal = async ( + db: Database, + input: { actorDid: string; cursor?: string; limit?: number }, +) => { + const alreadyIncluded = parseCursor(input.cursor) + const suggestions = await db.db + .selectFrom('suggested_follow') + .innerJoin('actor', 'actor.did', 'suggested_follow.did') + .if(alreadyIncluded.length > 0, (qb) => + qb.where('suggested_follow.order', 'not in', alreadyIncluded), + ) + .selectAll() + .orderBy('suggested_follow.order', 'asc') + .execute() + + // always include first two + const firstTwo = suggestions.filter( + (row) => row.order === 1 || row.order === 2, + ) + const rest = suggestions.filter((row) => row.order !== 1 && row.order !== 2) + const limited = firstTwo.concat(shuffle(rest)).slice(0, input.limit) + + // if the result set ends up getting larger, consider using a seed included in the cursor for for the randomized shuffle + const cursor = + limited.length > 0 + ? limited + .map((row) => row.order.toString()) + .concat(alreadyIncluded.map((id) => id.toString())) + .join(':') + : undefined + + return { + dids: limited.map((s) => s.did), + cursor, + } +} + +const getFollowSuggestionsRelativeTo = async ( + db: Database, + input: { + actorDid: string + relativeToDid: string + cursor?: string + limit?: number + }, +) => { + if (input.cursor) return { dids: [] } + const limit = input.limit ? Math.min(10, input.limit) : 10 + const actorsViewerFollows = db.db + .selectFrom('follow') + .where('creator', '=', input.actorDid) + .select('subjectDid') + const mostLikedAccounts = await db.db + .selectFrom( + db.db + .selectFrom('like') + .where('creator', '=', input.relativeToDid) + .select(sql`split_part(subject, '/', 3)`.as('subjectDid')) + .orderBy('sortAt', 'desc') + .limit(1000) // limit to 1000 + .as('likes'), + ) + .select('likes.subjectDid as did') + .select((qb) => qb.fn.count('likes.subjectDid').as('count')) + .where('likes.subjectDid', 'not in', actorsViewerFollows) + .where('likes.subjectDid', 'not in', [input.actorDid, input.relativeToDid]) + .groupBy('likes.subjectDid') + .orderBy('count', 'desc') + .limit(limit) + .execute() + const resultDids = mostLikedAccounts.map((a) => ({ did: a.did })) as { + did: string + }[] + + if (resultDids.length < limit) { + // backfill with popular accounts followed by actor + const mostPopularAccountsActorFollows = await db.db + .selectFrom('follow') + .innerJoin('profile_agg', 'follow.subjectDid', 'profile_agg.did') + .select('follow.subjectDid as did') + .where('follow.creator', '=', input.actorDid) + .where('follow.subjectDid', '!=', input.relativeToDid) + .where('follow.subjectDid', 'not in', actorsViewerFollows) + .if(resultDids.length > 0, (qb) => + qb.where( + 'subjectDid', + 'not in', + resultDids.map((a) => a.did), + ), + ) + .orderBy('profile_agg.followersCount', 'desc') + .limit(limit) + .execute() + + resultDids.push(...mostPopularAccountsActorFollows) + } + + if (resultDids.length < limit) { + // backfill with suggested_follow table + const additional = await db.db + .selectFrom('suggested_follow') + .where( + 'did', + 'not in', + // exclude any we already have + resultDids + .map((a) => a.did) + .concat([input.actorDid, input.relativeToDid]), + ) + // and aren't already followed by viewer + .where('did', 'not in', actorsViewerFollows) + .selectAll() + .execute() + + resultDids.push(...additional) + } + + return { dids: resultDids.map((x) => x.did) } +} + +const parseCursor = (cursor?: string): number[] => { + if (!cursor) { + return [] + } + try { + return cursor + .split(':') + .map((id) => parseInt(id, 10)) + .filter((id) => !isNaN(id)) + } catch { + return [] + } +} + +const shuffle = (arr: T[]): T[] => { + return arr + .map((value) => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value) +} diff --git a/packages/bsky/src/data-plane/server/routes/sync.ts b/packages/bsky/src/data-plane/server/routes/sync.ts new file mode 100644 index 00000000000..90222b628e7 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/sync.ts @@ -0,0 +1,16 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' + +export default (db: Database): Partial> => ({ + async getLatestRev(req) { + const res = await db.db + .selectFrom('actor_sync') + .where('did', '=', req.actorDid) + .select('repoRev') + .executeTakeFirst() + return { + rev: res?.repoRev ?? undefined, + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/threads.ts b/packages/bsky/src/data-plane/server/routes/threads.ts new file mode 100644 index 00000000000..5e19451d465 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/threads.ts @@ -0,0 +1,33 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { getAncestorsAndSelfQb, getDescendentsQb } from '../util' + +export default (db: Database): Partial> => ({ + async getThread(req) { + const { postUri, above, below } = req + const [ancestors, descendents] = await Promise.all([ + getAncestorsAndSelfQb(db.db, { + uri: postUri, + parentHeight: above, + }) + .selectFrom('ancestor') + .selectAll() + .execute(), + getDescendentsQb(db.db, { + uri: postUri, + depth: below, + }) + .selectFrom('descendent') + .innerJoin('post', 'post.uri', 'descendent.uri') + .orderBy('post.sortAt', 'desc') + .selectAll() + .execute(), + ]) + const uris = [ + ...ancestors.map((p) => p.uri), + ...descendents.map((p) => p.uri), + ] + return { uris } + }, +}) diff --git a/packages/bsky/src/indexer/subscription.ts b/packages/bsky/src/data-plane/server/subscription/index.ts similarity index 57% rename from packages/bsky/src/indexer/subscription.ts rename to packages/bsky/src/data-plane/server/subscription/index.ts index 907da954f49..a020eb0542d 100644 --- a/packages/bsky/src/indexer/subscription.ts +++ b/packages/bsky/src/data-plane/server/subscription/index.ts @@ -1,8 +1,10 @@ import assert from 'node:assert' import { CID } from 'multiformats/cid' import { AtUri } from '@atproto/syntax' -import { cborDecode, wait, handleAllSettledErrors } from '@atproto/common' -import { DisconnectError } from '@atproto/xrpc-server' +import { Subscription } from '@atproto/xrpc-server' +import { cborDecode, handleAllSettledErrors } from '@atproto/common' +import { ValidationError } from '@atproto/lexicon' +import { IdResolver } from '@atproto/identity' import { WriteOpAction, readCarWithRoot, @@ -10,136 +12,89 @@ import { def, Commit, } from '@atproto/repo' -import { ValidationError } from '@atproto/lexicon' -import * as message from '../lexicon/types/com/atproto/sync/subscribeRepos' -import { Leader } from '../db/leader' -import { IndexingService } from '../services/indexing' -import log from './logger' +import { ids, lexicons } from '../../../lexicon/lexicons' +import { OutputSchema as Message } from '../../../lexicon/types/com/atproto/sync/subscribeRepos' +import * as message from '../../../lexicon/types/com/atproto/sync/subscribeRepos' +import { subLogger as log } from '../../../logger' +import { IndexingService } from '../indexing' +import { Database } from '../db' import { ConsecutiveItem, ConsecutiveList, - LatestQueue, PartitionedQueue, - PerfectMap, ProcessableMessage, - jitter, loggableMessage, - strToInt, -} from '../subscription/util' -import IndexerContext from './context' +} from './util' +import { BackgroundQueue } from '../background' -export const INDEXER_SUB_LOCK_ID = 1200 // need one per partition - -export class IndexerSubscription { - destroyed = false - leader = new Leader(this.opts.subLockId || INDEXER_SUB_LOCK_ID, this.ctx.db) - processedCount = 0 - repoQueue = new PartitionedQueue({ - concurrency: this.opts.concurrency ?? Infinity, - }) - partitions = new PerfectMap() - partitionIds = this.opts.partitionIds +export class RepoSubscription { + ac = new AbortController() + running: Promise | undefined + cursor = 0 + seenSeq: number | null = null + repoQueue = new PartitionedQueue({ concurrency: Infinity }) + consecutive = new ConsecutiveList() + background: BackgroundQueue indexingSvc: IndexingService constructor( - public ctx: IndexerContext, - public opts: { - partitionIds: number[] - subLockId?: number - concurrency?: number - partitionBatchSize?: number + private opts: { + service: string + db: Database + idResolver: IdResolver + background: BackgroundQueue }, ) { - this.indexingSvc = ctx.services.indexing(ctx.db) + this.background = new BackgroundQueue(this.opts.db) + this.indexingSvc = new IndexingService( + this.opts.db, + this.opts.idResolver, + this.background, + ) } - async processEvents(opts: { signal: AbortSignal }) { - const done = () => this.destroyed || opts.signal.aborted - while (!done()) { - const results = await this.ctx.redis.readStreams( - this.partitionIds.map((id) => ({ - key: partitionKey(id), - cursor: this.partitions.get(id).cursor, - })), - { - blockMs: 1000, - count: this.opts.partitionBatchSize ?? 50, // events per stream - }, - ) - if (done()) break - for (const { key, messages } of results) { - const partition = this.partitions.get(partitionId(key)) - for (const msg of messages) { - const seq = strToInt(msg.cursor) - const envelope = getEnvelope(msg.contents) - partition.cursor = seq - const item = partition.consecutive.push(seq) - this.repoQueue.add(envelope.repo, async () => { - await this.handleMessage(partition, item, envelope) - }) + run() { + if (this.running) return + this.ac = new AbortController() + this.repoQueue = new PartitionedQueue({ concurrency: Infinity }) + this.consecutive = new ConsecutiveList() + this.running = this.process() + .catch((err) => { + if (err.name !== 'AbortError') { + // allow this to cause an unhandled rejection, let deployment handle the crash. + log.error({ err }, 'subscription crashed') + throw err } - } - await this.repoQueue.main.onEmpty() // backpressure - } + }) + .finally(() => (this.running = undefined)) } - async run() { - while (!this.destroyed) { - try { - const { ran } = await this.leader.run(async ({ signal }) => { - // initialize cursors to 0 (read from beginning of stream) - for (const id of this.partitionIds) { - this.partitions.set(id, new Partition(id, 0)) - } - // process events - await this.processEvents({ signal }) - }) - if (ran && !this.destroyed) { - throw new Error('Indexer sub completed, but should be persistent') - } - } catch (err) { - log.error({ err }, 'indexer sub error') - } - if (!this.destroyed) { - await wait(5000 + jitter(1000)) // wait then try to become leader + private async process() { + const sub = this.getSubscription() + for await (const msg of sub) { + const details = getMessageDetails(msg) + if ('info' in details) { + // These messages are not sequenced, we just log them and carry on + log.warn( + { provider: this.opts.service, message: loggableMessage(msg) }, + `sub ${details.info ? 'info' : 'unknown'} message`, + ) + continue } + const item = this.consecutive.push(details.seq) + this.repoQueue.add(details.repo, async () => { + await this.handleMessage(item, details) + }) + this.seenSeq = details.seq + await this.repoQueue.main.onEmpty() // backpressure } } - async requestReprocess(did: string) { - await this.repoQueue.add(did, async () => { - try { - await this.indexingSvc.indexRepo(did, undefined) - } catch (err) { - log.error({ did }, 'failed to reprocess repo') - } - }) - } - - async destroy() { - this.destroyed = true - await this.repoQueue.destroy() - await Promise.all( - [...this.partitions.values()].map((p) => p.cursorQueue.destroy()), - ) - this.leader.destroy(new DisconnectError()) - } - - async resume() { - this.destroyed = false - this.partitions = new Map() - this.repoQueue = new PartitionedQueue({ - concurrency: this.opts.concurrency ?? Infinity, - }) - await this.run() - } - private async handleMessage( - partition: Partition, item: ConsecutiveItem, envelope: Envelope, ) { - const msg = envelope.event + const msg = envelope.message try { if (message.isCommit(msg)) { await this.handleCommit(msg) @@ -163,16 +118,9 @@ export class IndexerSubscription { 'indexer message processing error', ) } finally { - this.processedCount++ const latest = item.complete().at(-1) if (latest !== undefined) { - partition.cursorQueue - .add(async () => { - await this.ctx.redis.trimStream(partition.key, latest + 1) - }) - .catch((err) => { - log.error({ err }, 'indexer cursor error') - }) + this.cursor = latest } } } @@ -253,6 +201,83 @@ export class IndexerSubscription { private async handleTombstone(msg: message.Tombstone) { await this.indexingSvc.tombstoneActor(msg.did) } + + private getSubscription() { + return new Subscription({ + service: this.opts.service, + method: ids.ComAtprotoSyncSubscribeRepos, + signal: this.ac.signal, + getParams: async () => { + return { cursor: this.cursor } + }, + onReconnectError: (err, reconnects, initial) => { + log.warn({ err, reconnects, initial }, 'sub reconnect') + }, + validate: (value) => { + try { + return lexicons.assertValidXrpcMessage( + ids.ComAtprotoSyncSubscribeRepos, + value, + ) + } catch (err) { + log.warn( + { + err, + seq: ifNumber(value?.['seq']), + repo: ifString(value?.['repo']), + commit: ifString(value?.['commit']?.toString()), + time: ifString(value?.['time']), + provider: this.opts.service, + }, + 'ingester sub skipped invalid message', + ) + } + }, + }) + } + + async destroy() { + this.ac.abort() + await this.running + await this.repoQueue.destroy() + await this.background.processAll() + } +} + +type Envelope = { + repo: string + message: ProcessableMessage +} + +function ifString(val: unknown): string | undefined { + return typeof val === 'string' ? val : undefined +} + +function ifNumber(val: unknown): number | undefined { + return typeof val === 'number' ? val : undefined +} + +function getMessageDetails(msg: Message): + | { info: message.Info | null } + | { + seq: number + repo: string + message: ProcessableMessage + } { + if (message.isCommit(msg)) { + return { seq: msg.seq, repo: msg.repo, message: msg } + } else if (message.isHandle(msg)) { + return { seq: msg.seq, repo: msg.did, message: msg } + } else if (message.isIdentity(msg)) { + return { seq: msg.seq, repo: msg.did, message: msg } + } else if (message.isMigrate(msg)) { + return { seq: msg.seq, repo: msg.did, message: msg } + } else if (message.isTombstone(msg)) { + return { seq: msg.seq, repo: msg.did, message: msg } + } else if (message.isInfo(msg)) { + return { info: msg } + } + return { info: null } } async function getOps( @@ -296,37 +321,6 @@ async function getOps( return { root, rootCid: car.root, ops } } -function getEnvelope(val: Record): Envelope { - assert(val.repo && val.event, 'malformed message contents') - return { - repo: val.repo.toString(), - event: cborDecode(val.event) as ProcessableMessage, - } -} - -type Envelope = { - repo: string - event: ProcessableMessage -} - -class Partition { - consecutive = new ConsecutiveList() - cursorQueue = new LatestQueue() - constructor(public id: number, public cursor: number) {} - get key() { - return partitionKey(this.id) - } -} - -function partitionId(key: string) { - assert(key.startsWith('repo:')) - return strToInt(key.replace('repo:', '')) -} - -function partitionKey(p: number) { - return `repo:${p}` -} - type PreparedCreate = { action: WriteOpAction.Create uri: AtUri diff --git a/packages/bsky/src/subscription/util.ts b/packages/bsky/src/data-plane/server/subscription/util.ts similarity index 93% rename from packages/bsky/src/subscription/util.ts rename to packages/bsky/src/data-plane/server/subscription/util.ts index fe367bcc24c..40bf30f73c8 100644 --- a/packages/bsky/src/subscription/util.ts +++ b/packages/bsky/src/data-plane/server/subscription/util.ts @@ -1,7 +1,7 @@ -import PQueue from 'p-queue' -import { OutputSchema as RepoMessage } from '../lexicon/types/com/atproto/sync/subscribeRepos' -import * as message from '../lexicon/types/com/atproto/sync/subscribeRepos' import assert from 'node:assert' +import PQueue from 'p-queue' +import { OutputSchema as RepoMessage } from '../../../lexicon/types/com/atproto/sync/subscribeRepos' +import * as message from '../../../lexicon/types/com/atproto/sync/subscribeRepos' // A queue with arbitrarily many partitions, each processing work sequentially. // Partitions are created lazily and taken out of memory when they go idle. @@ -108,6 +108,7 @@ export class PerfectMap extends Map { export type ProcessableMessage = | message.Commit | message.Handle + | message.Identity | message.Migrate | message.Tombstone @@ -127,6 +128,8 @@ export function loggableMessage(msg: RepoMessage) { } } else if (message.isHandle(msg)) { return msg + } else if (message.isIdentity(msg)) { + return msg } else if (message.isMigrate(msg)) { return msg } else if (message.isTombstone(msg)) { diff --git a/packages/bsky/src/services/feed/util.ts b/packages/bsky/src/data-plane/server/util.ts similarity index 55% rename from packages/bsky/src/services/feed/util.ts rename to packages/bsky/src/data-plane/server/util.ts index 83b5e59d705..d15b7ffa518 100644 --- a/packages/bsky/src/services/feed/util.ts +++ b/packages/bsky/src/data-plane/server/util.ts @@ -1,19 +1,77 @@ import { sql } from 'kysely' import { AtUri } from '@atproto/syntax' +import { ids } from '../../lexicon/lexicons' import { Record as PostRecord, ReplyRef, } from '../../lexicon/types/app/bsky/feed/post' -import { - Record as GateRecord, - isFollowingRule, - isListRule, - isMentionRule, -} from '../../lexicon/types/app/bsky/feed/threadgate' -import { isMention } from '../../lexicon/types/app/bsky/richtext/facet' -import { valuesList } from '../../db/util' -import DatabaseSchema from '../../db/database-schema' -import { ids } from '../../lexicon/lexicons' +import { Record as GateRecord } from '../../lexicon/types/app/bsky/feed/threadgate' +import DatabaseSchema from './db/database-schema' +import { valuesList } from './db/util' +import { parseThreadGate } from '../../views/util' + +export const getDescendentsQb = ( + db: DatabaseSchema, + opts: { + uri: string + depth: number // required, protects against cycles + }, +) => { + const { uri, depth } = opts + const query = db.withRecursive('descendent(uri, depth)', (cte) => { + return cte + .selectFrom('post') + .select(['post.uri as uri', sql`1`.as('depth')]) + .where(sql`1`, '<=', depth) + .where('replyParent', '=', uri) + .unionAll( + cte + .selectFrom('post') + .innerJoin('descendent', 'descendent.uri', 'post.replyParent') + .where('descendent.depth', '<', depth) + .select([ + 'post.uri as uri', + sql`descendent.depth + 1`.as('depth'), + ]), + ) + }) + return query +} + +export const getAncestorsAndSelfQb = ( + db: DatabaseSchema, + opts: { + uri: string + parentHeight: number // required, protects against cycles + }, +) => { + const { uri, parentHeight } = opts + const query = db.withRecursive( + 'ancestor(uri, ancestorUri, height)', + (cte) => { + return cte + .selectFrom('post') + .select([ + 'post.uri as uri', + 'post.replyParent as ancestorUri', + sql`0`.as('height'), + ]) + .where('uri', '=', uri) + .unionAll( + cte + .selectFrom('post') + .innerJoin('ancestor', 'ancestor.ancestorUri', 'post.uri') + .where('ancestor.height', '<', parentHeight) + .select([ + 'post.uri as uri', + 'post.replyParent as ancestorUri', + sql`ancestor.height + 1`.as('height'), + ]), + ) + }, + ) + return query +} export const invalidReplyRoot = ( reply: ReplyRef, @@ -35,46 +93,6 @@ export const invalidReplyRoot = ( // replying to a reply: ensure the parent is a reply for the same root post return parent.record.reply?.root.uri !== replyRoot } - -type ParsedThreadGate = { - canReply?: boolean - allowMentions?: boolean - allowFollowing?: boolean - allowListUris?: string[] -} - -export const parseThreadGate = ( - replierDid: string, - ownerDid: string, - rootPost: PostRecord | null, - gate: GateRecord | null, -): ParsedThreadGate => { - if (replierDid === ownerDid) { - return { canReply: true } - } - // if gate.allow is unset then *any* reply is allowed, if it is an empty array then *no* reply is allowed - if (!gate || !gate.allow) { - return { canReply: true } - } - - const allowMentions = !!gate.allow.find(isMentionRule) - const allowFollowing = !!gate.allow.find(isFollowingRule) - const allowListUris = gate.allow?.filter(isListRule).map((item) => item.list) - - // check mentions first since it's quick and synchronous - if (allowMentions) { - const isMentioned = rootPost?.facets?.some((facet) => { - return facet.features.some( - (item) => isMention(item) && item.did === replierDid, - ) - }) - if (isMentioned) { - return { canReply: true, allowMentions, allowFollowing, allowListUris } - } - } - return { allowMentions, allowFollowing, allowListUris } -} - export const violatesThreadGate = async ( db: DatabaseSchema, replierDid: string, @@ -132,9 +150,3 @@ export const postToThreadgateUri = (postUri: string) => { gateUri.collection = ids.AppBskyFeedThreadgate return gateUri.toString() } - -export const threadgateToPostUri = (gateUri: string) => { - const postUri = new AtUri(gateUri) - postUri.collection = ids.AppBskyFeedPost - return postUri.toString() -} diff --git a/packages/bsky/src/db/coordinator.ts b/packages/bsky/src/db/coordinator.ts deleted file mode 100644 index a8f4cc3016c..00000000000 --- a/packages/bsky/src/db/coordinator.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Migrator } from 'kysely' -import PrimaryDatabase from './primary' -import Database from './db' -import { PgOptions } from './types' -import { dbLogger } from '../logger' - -type ReplicaTag = 'timeline' | 'feed' | 'search' | 'thread' | '*' -type ReplicaOptions = PgOptions & { tags?: ReplicaTag[] } - -type CoordinatorOptions = { - schema?: string - primary: PgOptions - replicas?: ReplicaOptions[] -} - -type ReplicaGroup = { - dbs: Database[] - roundRobinIdx: number -} - -export class DatabaseCoordinator { - migrator: Migrator - destroyed = false - - private primary: PrimaryDatabase - private allReplicas: Database[] - private tagged: Record - private untagged: ReplicaGroup - private tagWarns = new Set() - - constructor(public opts: CoordinatorOptions) { - this.primary = new PrimaryDatabase({ - schema: opts.schema, - ...opts.primary, - }) - this.allReplicas = [] - this.tagged = {} - this.untagged = { - dbs: [], - roundRobinIdx: 0, - } - for (const cfg of opts.replicas ?? []) { - const db = new Database({ - schema: opts.schema, - ...cfg, - }) - this.allReplicas.push(db) - // setup different groups of replicas based on tag, each round-robins separately. - if (cfg.tags?.length) { - for (const tag of cfg.tags) { - if (tag === '*') { - this.untagged.dbs.push(db) - } else { - this.tagged[tag] ??= { - dbs: [], - roundRobinIdx: 0, - } - this.tagged[tag].dbs.push(db) - } - } - } else { - this.untagged.dbs.push(db) - } - } - // guarantee there is always a replica around to service any query, falling back to primary. - if (!this.untagged.dbs.length) { - if (this.allReplicas.length) { - this.untagged.dbs = [...this.allReplicas] - } else { - this.untagged.dbs = [this.primary] - } - } - } - - getPrimary(): PrimaryDatabase { - return this.primary - } - - getReplicas(): Database[] { - return this.allReplicas - } - - getReplica(tag?: ReplicaTag): Database { - if (tag && this.tagged[tag]) { - return nextDb(this.tagged[tag]) - } - if (tag && !this.tagWarns.has(tag)) { - this.tagWarns.add(tag) - dbLogger.warn({ tag }, 'no replica for tag, falling back to any replica') - } - return nextDb(this.untagged) - } - - async close(): Promise { - await Promise.all([ - this.primary.close(), - ...this.allReplicas.map((db) => db.close()), - ]) - } -} - -// @NOTE mutates group incrementing roundRobinIdx -const nextDb = (group: ReplicaGroup) => { - const db = group.dbs[group.roundRobinIdx] - group.roundRobinIdx = (group.roundRobinIdx + 1) % group.dbs.length - return db -} diff --git a/packages/bsky/src/db/db.ts b/packages/bsky/src/db/db.ts deleted file mode 100644 index cb58eb4742b..00000000000 --- a/packages/bsky/src/db/db.ts +++ /dev/null @@ -1,91 +0,0 @@ -import assert from 'assert' -import { Kysely, PostgresDialect } from 'kysely' -import { Pool as PgPool, types as pgTypes } from 'pg' -import DatabaseSchema, { DatabaseSchemaType } from './database-schema' -import { PgOptions } from './types' -import { dbLogger } from '../logger' - -export class Database { - pool: PgPool - db: DatabaseSchema - destroyed = false - isPrimary = false - - constructor( - public opts: PgOptions, - instances?: { db: DatabaseSchema; pool: PgPool }, - ) { - // if instances are provided, use those - if (instances) { - this.db = instances.db - this.pool = instances.pool - return - } - - // else create a pool & connect - const { schema, url } = opts - const pool = - opts.pool ?? - new PgPool({ - connectionString: url, - max: opts.poolSize, - maxUses: opts.poolMaxUses, - idleTimeoutMillis: opts.poolIdleTimeoutMs, - }) - - // Select count(*) and other pg bigints as js integer - pgTypes.setTypeParser(pgTypes.builtins.INT8, (n) => parseInt(n, 10)) - - // Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema) - if (schema && !/^[a-z_]+$/i.test(schema)) { - throw new Error(`Postgres schema must only contain [A-Za-z_]: ${schema}`) - } - - pool.on('error', onPoolError) - pool.on('connect', (client) => { - client.on('error', onClientError) - // Used for trigram indexes, e.g. on actor search - client.query('SET pg_trgm.word_similarity_threshold TO .4;') - if (schema) { - // Shared objects such as extensions will go in the public schema - client.query(`SET search_path TO "${schema}",public;`) - } - }) - - this.pool = pool - this.db = new Kysely({ - dialect: new PostgresDialect({ pool }), - }) - } - - get schema(): string | undefined { - return this.opts.schema - } - - get isTransaction() { - return this.db.isTransaction - } - - assertTransaction() { - assert(this.isTransaction, 'Transaction required') - } - - assertNotTransaction() { - assert(!this.isTransaction, 'Cannot be in a transaction') - } - - asPrimary(): Database { - throw new Error('Primary db required') - } - - async close(): Promise { - if (this.destroyed) return - await this.db.destroy() - this.destroyed = true - } -} - -export default Database - -const onPoolError = (err: Error) => dbLogger.error({ err }, 'db pool error') -const onClientError = (err: Error) => dbLogger.error({ err }, 'db client error') diff --git a/packages/bsky/src/db/index.ts b/packages/bsky/src/db/index.ts deleted file mode 100644 index 1c5886fb10e..00000000000 --- a/packages/bsky/src/db/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './primary' -export * from './db' -export * from './coordinator' diff --git a/packages/bsky/src/db/leader.ts b/packages/bsky/src/db/leader.ts deleted file mode 100644 index ebd44bf98d6..00000000000 --- a/packages/bsky/src/db/leader.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { PoolClient } from 'pg' -import PrimaryDatabase from './primary' - -export class Leader { - session: Session | null = null - constructor(public id: number, public db: PrimaryDatabase) {} - - async run( - task: (ctx: { signal: AbortSignal }) => Promise, - ): Promise> { - const session = await this.lock() - if (!session) return { ran: false } - try { - const result = await task({ signal: session.abortController.signal }) - return { ran: true, result } - } finally { - this.release() - } - } - - destroy(err?: Error) { - this.session?.abortController.abort(err) - } - - private async lock(): Promise { - if (this.session) { - return null - } - - // Postgres implementation uses advisory locking, automatically released by ending connection. - - const client = await this.db.pool.connect() - try { - const lock = await client.query( - 'SELECT pg_try_advisory_lock($1) as acquired', - [this.id], - ) - if (!lock.rows[0].acquired) { - client.release() - return null - } - } catch (err) { - client.release(true) - throw err - } - - const abortController = new AbortController() - client.once('error', (err) => abortController.abort(err)) - this.session = { abortController, client } - return this.session - } - - private release() { - // The flag ensures the connection is destroyed on release, not reused. - // This is required, as that is how the pg advisory lock is released. - this.session?.client.release(true) - this.session = null - } -} - -type Session = { abortController: AbortController; client: PoolClient } - -type RunResult = { ran: false } | { ran: true; result: T } diff --git a/packages/bsky/src/db/migrations/20231205T000257238Z-remove-did-cache.ts b/packages/bsky/src/db/migrations/20231205T000257238Z-remove-did-cache.ts deleted file mode 100644 index 6b57a88bbb9..00000000000 --- a/packages/bsky/src/db/migrations/20231205T000257238Z-remove-did-cache.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Kysely } from 'kysely' - -export async function up(db: Kysely): Promise { - await db.schema.dropTable('did_cache').execute() -} - -export async function down(db: Kysely): Promise { - await db.schema - .createTable('did_cache') - .addColumn('did', 'varchar', (col) => col.primaryKey()) - .addColumn('doc', 'jsonb', (col) => col.notNull()) - .addColumn('updatedAt', 'bigint', (col) => col.notNull()) - .execute() -} diff --git a/packages/bsky/src/db/views.ts b/packages/bsky/src/db/views.ts deleted file mode 100644 index d5aa9941436..00000000000 --- a/packages/bsky/src/db/views.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { jitter, wait } from '@atproto/common' -import { Leader } from './leader' -import { dbLogger } from '../logger' -import { PrimaryDatabase } from '.' - -export const VIEW_MAINTAINER_ID = 1010 -const VIEWS = ['algo_whats_hot_view'] - -export class ViewMaintainer { - leader = new Leader(VIEW_MAINTAINER_ID, this.db) - destroyed = false - - // @NOTE the db must be authed as the owner of the materialized view, per postgres. - constructor(public db: PrimaryDatabase, public intervalSec = 60) {} - - async run() { - while (!this.destroyed) { - try { - const { ran } = await this.leader.run(async ({ signal }) => { - await this.db.maintainMaterializedViews({ - signal, - views: VIEWS, - intervalSec: this.intervalSec, - }) - }) - if (ran && !this.destroyed) { - throw new Error('View maintainer completed, but should be persistent') - } - } catch (err) { - dbLogger.error( - { - err, - views: VIEWS, - intervalSec: this.intervalSec, - lockId: VIEW_MAINTAINER_ID, - }, - 'view maintainer errored', - ) - } - if (!this.destroyed) { - await wait(10000 + jitter(2000)) - } - } - } - - destroy() { - this.destroyed = true - this.leader.destroy() - } -} diff --git a/packages/bsky/src/did-cache.ts b/packages/bsky/src/did-cache.ts deleted file mode 100644 index 9e45d0d8b30..00000000000 --- a/packages/bsky/src/did-cache.ts +++ /dev/null @@ -1,87 +0,0 @@ -import PQueue from 'p-queue' -import { CacheResult, DidCache, DidDocument } from '@atproto/identity' -import { cacheLogger as log } from './logger' -import { Redis } from './redis' - -type CacheOptions = { - staleTTL: number - maxTTL: number -} - -export class DidRedisCache implements DidCache { - public pQueue: PQueue | null // null during teardown - - constructor(public redis: Redis, public opts: CacheOptions) { - this.pQueue = new PQueue() - } - - async cacheDid(did: string, doc: DidDocument): Promise { - const item = JSON.stringify({ - doc, - updatedAt: Date.now(), - }) - await this.redis.set(did, item, this.opts.maxTTL) - } - - async refreshCache( - did: string, - getDoc: () => Promise, - ): Promise { - this.pQueue?.add(async () => { - try { - const doc = await getDoc() - if (doc) { - await this.cacheDid(did, doc) - } else { - await this.clearEntry(did) - } - } catch (err) { - log.error({ did, err }, 'refreshing did cache failed') - } - }) - } - - async checkCache(did: string): Promise { - let got: string | null - try { - got = await this.redis.get(did) - } catch (err) { - got = null - log.error({ did, err }, 'error fetching did from cache') - } - if (!got) return null - const { doc, updatedAt } = JSON.parse(got) as CacheResult - const now = Date.now() - const expired = now > updatedAt + this.opts.maxTTL - const stale = now > updatedAt + this.opts.staleTTL - return { - doc, - updatedAt, - did, - stale, - expired, - } - } - - async clearEntry(did: string): Promise { - await this.redis.del(did) - } - - async clear(): Promise { - throw new Error('Not implemented for redis cache') - } - - async processAll() { - await this.pQueue?.onIdle() - } - - async destroy() { - const pQueue = this.pQueue - this.pQueue = null - pQueue?.pause() - pQueue?.clear() - await pQueue?.onIdle() - } -} - -export default DidRedisCache diff --git a/packages/bsky/src/hydration/actor.ts b/packages/bsky/src/hydration/actor.ts new file mode 100644 index 00000000000..3ed7f1b036e --- /dev/null +++ b/packages/bsky/src/hydration/actor.ts @@ -0,0 +1,149 @@ +import { DataPlaneClient } from '../data-plane/client' +import { Record as ProfileRecord } from '../lexicon/types/app/bsky/actor/profile' +import { + HydrationMap, + parseRecordBytes, + parseString, + safeTakedownRef, +} from './util' + +export type Actor = { + did: string + handle?: string + profile?: ProfileRecord + profileCid?: string + profileTakedownRef?: string + sortedAt?: Date + takedownRef?: string +} + +export type Actors = HydrationMap + +export type ProfileViewerState = { + muted?: boolean + mutedByList?: string + blockedBy?: string + blocking?: string + blockedByList?: string + blockingByList?: string + following?: string + followedBy?: string +} + +export type ProfileViewerStates = HydrationMap + +export type ProfileAgg = { + followers: number + follows: number + posts: number +} + +export type ProfileAggs = HydrationMap + +export class ActorHydrator { + constructor(public dataplane: DataPlaneClient) {} + + async getRepoRevSafe(did: string | null): Promise { + if (!did) return null + try { + const res = await this.dataplane.getLatestRev({ actorDid: did }) + return parseString(res.rev) ?? null + } catch { + return null + } + } + + async getDids(handleOrDids: string[]): Promise<(string | undefined)[]> { + const handles = handleOrDids.filter((actor) => !actor.startsWith('did:')) + const res = handles.length + ? await this.dataplane.getDidsByHandles({ handles }) + : { dids: [] } + const didByHandle = handles.reduce((acc, cur, i) => { + const did = res.dids[i] + if (did && did.length > 0) { + return acc.set(cur, did) + } + return acc + }, new Map() as Map) + return handleOrDids.map((id) => + id.startsWith('did:') ? id : didByHandle.get(id), + ) + } + + async getDidsDefined(handleOrDids: string[]): Promise { + const res = await this.getDids(handleOrDids) + // @ts-ignore + return res.filter((did) => did !== undefined) + } + + async getActors(dids: string[], includeTakedowns = false): Promise { + if (!dids.length) return new HydrationMap() + const res = await this.dataplane.getActors({ dids }) + return dids.reduce((acc, did, i) => { + const actor = res.actors[i] + if ( + !actor.exists || + (actor.takenDown && !includeTakedowns) || + !!actor.tombstonedAt + ) { + return acc.set(did, null) + } + const profile = + includeTakedowns || !actor.profile?.takenDown + ? actor.profile + : undefined + return acc.set(did, { + did, + handle: parseString(actor.handle), + profile: parseRecordBytes(profile?.record), + profileCid: profile?.cid, + profileTakedownRef: safeTakedownRef(profile), + sortedAt: profile?.sortedAt?.toDate(), + takedownRef: safeTakedownRef(actor), + }) + }, new HydrationMap()) + } + + // "naive" because this method does not verify the existence of the list itself + // a later check in the main hydrator will remove list uris that have been deleted or + // repurposed to "curate lists" + async getProfileViewerStatesNaive( + dids: string[], + viewer: string, + ): Promise { + if (!dids.length) return new HydrationMap() + const res = await this.dataplane.getRelationships({ + actorDid: viewer, + targetDids: dids, + }) + return dids.reduce((acc, did, i) => { + const rels = res.relationships[i] + if (viewer === did) { + // ignore self-follows, self-mutes, self-blocks + return acc.set(did, {}) + } + return acc.set(did, { + muted: rels.muted ?? false, + mutedByList: parseString(rels.mutedByList), + blockedBy: parseString(rels.blockedBy), + blocking: parseString(rels.blocking), + blockedByList: parseString(rels.blockedByList), + blockingByList: parseString(rels.blockingByList), + following: parseString(rels.following), + followedBy: parseString(rels.followedBy), + }) + }, new HydrationMap()) + } + + async getProfileAggregates(dids: string[]): Promise { + if (!dids.length) return new HydrationMap() + const counts = await this.dataplane.getCountsForUsers({ dids }) + return dids.reduce((acc, did, i) => { + return acc.set(did, { + followers: counts.followers[i] ?? 0, + follows: counts.following[i] ?? 0, + posts: counts.posts[i] ?? 0, + }) + }, new HydrationMap()) + } +} diff --git a/packages/bsky/src/hydration/feed.ts b/packages/bsky/src/hydration/feed.ts new file mode 100644 index 00000000000..edd29b851e8 --- /dev/null +++ b/packages/bsky/src/hydration/feed.ts @@ -0,0 +1,204 @@ +import { DataPlaneClient } from '../data-plane/client' +import { Record as PostRecord } from '../lexicon/types/app/bsky/feed/post' +import { Record as LikeRecord } from '../lexicon/types/app/bsky/feed/like' +import { Record as RepostRecord } from '../lexicon/types/app/bsky/feed/repost' +import { Record as FeedGenRecord } from '../lexicon/types/app/bsky/feed/generator' +import { Record as ThreadgateRecord } from '../lexicon/types/app/bsky/feed/threadgate' +import { + HydrationMap, + RecordInfo, + parseRecord, + parseString, + split, +} from './util' +import { AtUri } from '@atproto/syntax' +import { ids } from '../lexicon/lexicons' + +export type Post = RecordInfo & { violatesThreadGate: boolean } +export type Posts = HydrationMap + +export type PostViewerState = { + like?: string + repost?: string +} + +export type PostViewerStates = HydrationMap + +export type PostAgg = { + likes: number + replies: number + reposts: number +} + +export type PostAggs = HydrationMap + +export type Like = RecordInfo +export type Likes = HydrationMap + +export type Repost = RecordInfo +export type Reposts = HydrationMap + +export type FeedGenAgg = { + likes: number +} + +export type FeedGenAggs = HydrationMap + +export type FeedGen = RecordInfo +export type FeedGens = HydrationMap + +export type FeedGenViewerState = { + like?: string +} + +export type FeedGenViewerStates = HydrationMap + +export type Threadgate = RecordInfo +export type Threadgates = HydrationMap + +export type ItemRef = { uri: string; cid?: string } + +// @NOTE the feed item types in the protos for author feeds and timelines +// technically have additional fields, not supported by the mock dataplane. +export type FeedItem = { post: ItemRef; repost?: ItemRef } + +export class FeedHydrator { + constructor(public dataplane: DataPlaneClient) {} + + async getPosts( + uris: string[], + includeTakedowns = false, + given = new HydrationMap(), + ): Promise { + const [have, need] = split(uris, (uri) => given.has(uri)) + const base = have.reduce( + (acc, uri) => acc.set(uri, given.get(uri) ?? null), + new HydrationMap(), + ) + if (!need.length) return base + const res = await this.dataplane.getPostRecords({ uris: need }) + return need.reduce((acc, uri, i) => { + const record = parseRecord(res.records[i], includeTakedowns) + const violatesThreadGate = res.meta[i].violatesThreadGate + return acc.set(uri, record ? { ...record, violatesThreadGate } : null) + }, base) + } + + async getPostViewerStates( + refs: ItemRef[], + viewer: string, + ): Promise { + if (!refs.length) return new HydrationMap() + const [likes, reposts] = await Promise.all([ + this.dataplane.getLikesByActorAndSubjects({ + actorDid: viewer, + refs, + }), + this.dataplane.getRepostsByActorAndSubjects({ + actorDid: viewer, + refs, + }), + ]) + return refs.reduce((acc, { uri }, i) => { + return acc.set(uri, { + like: parseString(likes.uris[i]), + repost: parseString(reposts.uris[i]), + }) + }, new HydrationMap()) + } + + async getPostAggregates(refs: ItemRef[]): Promise { + if (!refs.length) return new HydrationMap() + const counts = await this.dataplane.getInteractionCounts({ refs }) + return refs.reduce((acc, { uri }, i) => { + return acc.set(uri, { + likes: counts.likes[i] ?? 0, + reposts: counts.reposts[i] ?? 0, + replies: counts.replies[i] ?? 0, + }) + }, new HydrationMap()) + } + + async getFeedGens( + uris: string[], + includeTakedowns = false, + ): Promise { + if (!uris.length) return new HydrationMap() + const res = await this.dataplane.getFeedGeneratorRecords({ uris }) + return uris.reduce((acc, uri, i) => { + const record = parseRecord( + res.records[i], + includeTakedowns, + ) + return acc.set(uri, record ?? null) + }, new HydrationMap()) + } + + async getFeedGenViewerStates( + uris: string[], + viewer: string, + ): Promise { + if (!uris.length) return new HydrationMap() + const likes = await this.dataplane.getLikesByActorAndSubjects({ + actorDid: viewer, + refs: uris.map((uri) => ({ uri })), + }) + return uris.reduce((acc, uri, i) => { + return acc.set(uri, { + like: parseString(likes.uris[i]), + }) + }, new HydrationMap()) + } + + async getFeedGenAggregates(refs: ItemRef[]): Promise { + if (!refs.length) return new HydrationMap() + const counts = await this.dataplane.getInteractionCounts({ refs }) + return refs.reduce((acc, { uri }, i) => { + return acc.set(uri, { + likes: counts.likes[i] ?? 0, + }) + }, new HydrationMap()) + } + + async getThreadgatesForPosts( + postUris: string[], + includeTakedowns = false, + ): Promise { + if (!postUris.length) return new HydrationMap() + const uris = postUris.map((uri) => { + const parsed = new AtUri(uri) + return AtUri.make( + parsed.hostname, + ids.AppBskyFeedThreadgate, + parsed.rkey, + ).toString() + }) + const res = await this.dataplane.getThreadGateRecords({ uris }) + return uris.reduce((acc, uri, i) => { + const record = parseRecord( + res.records[i], + includeTakedowns, + ) + return acc.set(uri, record ?? null) + }, new HydrationMap()) + } + + // @TODO may not be supported yet by data plane + async getLikes(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() + const res = await this.dataplane.getLikeRecords({ uris }) + return uris.reduce((acc, uri, i) => { + const record = parseRecord(res.records[i], includeTakedowns) + return acc.set(uri, record ?? null) + }, new HydrationMap()) + } + + async getReposts(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() + const res = await this.dataplane.getRepostRecords({ uris }) + return uris.reduce((acc, uri, i) => { + const record = parseRecord(res.records[i], includeTakedowns) + return acc.set(uri, record ?? null) + }, new HydrationMap()) + } +} diff --git a/packages/bsky/src/hydration/graph.ts b/packages/bsky/src/hydration/graph.ts new file mode 100644 index 00000000000..efcd2fb9948 --- /dev/null +++ b/packages/bsky/src/hydration/graph.ts @@ -0,0 +1,195 @@ +import { Record as FollowRecord } from '../lexicon/types/app/bsky/graph/follow' +import { Record as BlockRecord } from '../lexicon/types/app/bsky/graph/block' +import { Record as ListRecord } from '../lexicon/types/app/bsky/graph/list' +import { Record as ListItemRecord } from '../lexicon/types/app/bsky/graph/listitem' +import { DataPlaneClient } from '../data-plane/client' +import { HydrationMap, RecordInfo, parseRecord } from './util' +import { FollowInfo } from '../proto/bsky_pb' + +export type List = RecordInfo +export type Lists = HydrationMap + +export type ListItem = RecordInfo +export type ListItems = HydrationMap + +export type ListViewerState = { + viewerMuted?: string + viewerListBlockUri?: string + viewerInList?: string +} + +export type ListViewerStates = HydrationMap + +export type Follow = RecordInfo +export type Follows = HydrationMap + +export type Block = RecordInfo + +export type RelationshipPair = [didA: string, didB: string] + +const dedupePairs = (pairs: RelationshipPair[]): RelationshipPair[] => { + const mapped = pairs.reduce((acc, cur) => { + const sorted = ([...cur] as RelationshipPair).sort() + acc[sorted.join('-')] = sorted + return acc + }, {} as Record) + return Object.values(mapped) +} + +export class Blocks { + _blocks: Map = new Map() + constructor() {} + + static key(didA: string, didB: string): string { + return [didA, didB].sort().join(',') + } + + set(didA: string, didB: string, exists: boolean): Blocks { + const key = Blocks.key(didA, didB) + this._blocks.set(key, exists) + return this + } + + has(didA: string, didB: string): boolean { + const key = Blocks.key(didA, didB) + return this._blocks.has(key) + } + + isBlocked(didA: string, didB: string): boolean { + if (didA === didB) return false // ignore self-blocks + const key = Blocks.key(didA, didB) + return this._blocks.get(key) ?? false + } + + merge(blocks: Blocks): Blocks { + blocks._blocks.forEach((exists, key) => { + this._blocks.set(key, exists) + }) + return this + } +} + +export class GraphHydrator { + constructor(public dataplane: DataPlaneClient) {} + + async getLists(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() + const res = await this.dataplane.getListRecords({ uris }) + return uris.reduce((acc, uri, i) => { + const record = parseRecord(res.records[i], includeTakedowns) + return acc.set(uri, record ?? null) + }, new HydrationMap()) + } + + // @TODO may not be supported yet by data plane + async getListItems( + uris: string[], + includeTakedowns = false, + ): Promise { + if (!uris.length) return new HydrationMap() + const res = await this.dataplane.getListItemRecords({ uris }) + return uris.reduce((acc, uri, i) => { + const record = parseRecord( + res.records[i], + includeTakedowns, + ) + return acc.set(uri, record ?? null) + }, new HydrationMap()) + } + + async getListViewerStates( + uris: string[], + viewer: string, + ): Promise { + if (!uris.length) return new HydrationMap() + const mutesAndBlocks = await Promise.all( + uris.map((uri) => this.getMutesAndBlocks(uri, viewer)), + ) + const listMemberships = await this.dataplane.getListMembership({ + actorDid: viewer, + listUris: uris, + }) + return uris.reduce((acc, uri, i) => { + return acc.set(uri, { + viewerMuted: mutesAndBlocks[i].muted ? uri : undefined, + viewerListBlockUri: mutesAndBlocks[i].listBlockUri || undefined, + viewerInList: listMemberships.listitemUris[i], + }) + }, new HydrationMap()) + } + + private async getMutesAndBlocks(uri: string, viewer: string) { + const [muted, listBlockUri] = await Promise.all([ + this.dataplane.getMutelistSubscription({ + actorDid: viewer, + listUri: uri, + }), + this.dataplane.getBlocklistSubscription({ + actorDid: viewer, + listUri: uri, + }), + ]) + return { + muted: muted.subscribed, + listBlockUri: listBlockUri.listblockUri, + } + } + + async getBidirectionalBlocks(pairs: RelationshipPair[]): Promise { + if (!pairs.length) return new Blocks() + const deduped = dedupePairs(pairs).map(([a, b]) => ({ a, b })) + const res = await this.dataplane.getBlockExistence({ pairs: deduped }) + const blocks = new Blocks() + for (let i = 0; i < deduped.length; i++) { + const pair = deduped[i] + blocks.set(pair.a, pair.b, res.exists[i] ?? false) + } + return blocks + } + + async getFollows(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() + const res = await this.dataplane.getFollowRecords({ uris }) + return uris.reduce((acc, uri, i) => { + const record = parseRecord(res.records[i], includeTakedowns) + return acc.set(uri, record ?? null) + }, new HydrationMap()) + } + + async getBlocks(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() + const res = await this.dataplane.getBlockRecords({ uris }) + return uris.reduce((acc, uri, i) => { + const record = parseRecord(res.records[i], includeTakedowns) + return acc.set(uri, record ?? null) + }, new HydrationMap()) + } + + async getActorFollows(input: { + did: string + cursor?: string + limit?: number + }): Promise<{ follows: FollowInfo[]; cursor: string }> { + const { did, cursor, limit } = input + const res = await this.dataplane.getFollows({ + actorDid: did, + cursor, + limit, + }) + return { follows: res.follows, cursor: res.cursor } + } + + async getActorFollowers(input: { + did: string + cursor?: string + limit?: number + }): Promise<{ followers: FollowInfo[]; cursor: string }> { + const { did, cursor, limit } = input + const res = await this.dataplane.getFollowers({ + actorDid: did, + cursor, + limit, + }) + return { followers: res.followers, cursor: res.cursor } + } +} diff --git a/packages/bsky/src/hydration/hydrator.ts b/packages/bsky/src/hydration/hydrator.ts new file mode 100644 index 00000000000..c79df963a7b --- /dev/null +++ b/packages/bsky/src/hydration/hydrator.ts @@ -0,0 +1,726 @@ +import assert from 'assert' +import { mapDefined } from '@atproto/common' +import { AtUri } from '@atproto/syntax' +import { DataPlaneClient } from '../data-plane/client' +import { Notification } from '../proto/bsky_pb' +import { ids } from '../lexicon/lexicons' +import { isMain as isEmbedRecord } from '../lexicon/types/app/bsky/embed/record' +import { isMain as isEmbedRecordWithMedia } from '../lexicon/types/app/bsky/embed/recordWithMedia' +import { isListRule } from '../lexicon/types/app/bsky/feed/threadgate' +import { + ActorHydrator, + ProfileAggs, + Actors, + ProfileViewerStates, + ProfileViewerState, +} from './actor' +import { + Follows, + GraphHydrator, + ListItems, + ListViewerStates, + Lists, + RelationshipPair, +} from './graph' +import { LabelHydrator, Labels } from './label' +import { HydrationMap, RecordInfo, didFromUri, urisByCollection } from './util' +import { + FeedGenAggs, + FeedGens, + FeedGenViewerStates, + FeedHydrator, + Likes, + Post, + Posts, + Reposts, + PostAggs, + PostViewerStates, + Threadgates, + FeedItem, + ItemRef, +} from './feed' + +export type HydrationState = { + viewer?: string | null + actors?: Actors + profileViewers?: ProfileViewerStates + profileAggs?: ProfileAggs + posts?: Posts + postAggs?: PostAggs + postViewers?: PostViewerStates + postBlocks?: PostBlocks + reposts?: Reposts + follows?: Follows + followBlocks?: FollowBlocks + threadgates?: Threadgates + lists?: Lists + listViewers?: ListViewerStates + listItems?: ListItems + likes?: Likes + labels?: Labels + feedgens?: FeedGens + feedgenViewers?: FeedGenViewerStates + feedgenAggs?: FeedGenAggs +} + +export type PostBlock = { embed: boolean; reply: boolean } +export type PostBlocks = HydrationMap +type PostBlockPairs = { embed?: RelationshipPair; reply?: RelationshipPair } + +export type FollowBlock = boolean +export type FollowBlocks = HydrationMap + +export class Hydrator { + actor: ActorHydrator + feed: FeedHydrator + graph: GraphHydrator + label: LabelHydrator + + constructor( + public dataplane: DataPlaneClient, + public opts?: { labelsFromIssuerDids?: string[] }, + ) { + this.actor = new ActorHydrator(dataplane) + this.feed = new FeedHydrator(dataplane) + this.graph = new GraphHydrator(dataplane) + this.label = new LabelHydrator(dataplane, opts) + } + + // app.bsky.actor.defs#profileView + // - profile viewer + // - list basic + // Note: builds on the naive profile viewer hydrator and removes references to lists that have been deleted + async hydrateProfileViewers( + dids: string[], + viewer: string, + ): Promise { + const profileViewers = await this.actor.getProfileViewerStatesNaive( + dids, + viewer, + ) + const listUris: string[] = [] + profileViewers?.forEach((item) => { + listUris.push(...listUrisFromProfileViewer(item)) + }) + const listState = await this.hydrateListsBasic(listUris, viewer) + // if a list no longer exists or is not a mod list, then remove from viewer state + profileViewers?.forEach((item) => { + removeNonModListsFromProfileViewer(item, listState) + }) + return mergeStates(listState, { + profileViewers, + viewer, + }) + } + + // app.bsky.actor.defs#profileView + // - profile + // - list basic + async hydrateProfiles( + dids: string[], + viewer: string | null, + includeTakedowns = false, + ): Promise { + const [actors, labels, profileViewersState] = await Promise.all([ + this.actor.getActors(dids, includeTakedowns), + this.label.getLabelsForSubjects(labelSubjectsForDid(dids)), + viewer ? this.hydrateProfileViewers(dids, viewer) : undefined, + ]) + return mergeStates(profileViewersState ?? {}, { + actors, + labels, + viewer, + }) + } + + // app.bsky.actor.defs#profileViewBasic + // - profile basic + // - profile + // - list basic + async hydrateProfilesBasic( + dids: string[], + viewer: string | null, + includeTakedowns = false, + ): Promise { + return this.hydrateProfiles(dids, viewer, includeTakedowns) + } + + // app.bsky.actor.defs#profileViewDetailed + // - profile detailed + // - profile + // - list basic + async hydrateProfilesDetailed( + dids: string[], + viewer: string | null, + includeTakedowns = false, + ): Promise { + const [state, profileAggs] = await Promise.all([ + this.hydrateProfiles(dids, viewer, includeTakedowns), + this.actor.getProfileAggregates(dids), + ]) + return { + ...state, + profileAggs, + } + } + + // app.bsky.graph.defs#listView + // - list + // - profile basic + async hydrateLists( + uris: string[], + viewer: string | null, + ): Promise { + const [listsState, profilesState] = await Promise.all([ + await this.hydrateListsBasic(uris, viewer), + await this.hydrateProfilesBasic(uris.map(didFromUri), viewer), + ]) + return mergeStates(listsState, profilesState) + } + + // app.bsky.graph.defs#listViewBasic + // - list basic + async hydrateListsBasic( + uris: string[], + viewer: string | null, + ): Promise { + const [lists, listViewers] = await Promise.all([ + this.graph.getLists(uris), + viewer ? this.graph.getListViewerStates(uris, viewer) : undefined, + ]) + return { lists, listViewers, viewer } + } + + // app.bsky.graph.defs#listItemView + // - list item + // - profile + // - list basic + async hydrateListItems( + uris: string[], + viewer: string | null, + ): Promise { + const listItems = await this.graph.getListItems(uris) + const dids: string[] = [] + listItems.forEach((item) => { + if (item) { + dids.push(item.record.subject) + } + }) + const profileState = await this.hydrateProfiles(dids, viewer) + return mergeStates(profileState, { listItems, viewer }) + } + + // app.bsky.feed.defs#postView + // - post + // - profile + // - list basic + // - list + // - profile + // - list basic + // - feedgen + // - profile + // - list basic + async hydratePosts( + refs: ItemRef[], + viewer: string | null, + includeTakedowns = false, + state: HydrationState = {}, + ): Promise { + const uris = refs.map((ref) => ref.uri) + const postsLayer0 = await this.feed.getPosts( + uris, + includeTakedowns, + state.posts, + ) + // first level embeds plus thread roots we haven't fetched yet + const urisLayer1 = nestedRecordUrisFromPosts(postsLayer0) + const additionalRootUris = rootUrisFromPosts(postsLayer0) // supports computing threadgates + const urisLayer1ByCollection = urisByCollection(urisLayer1) + const postUrisLayer1 = urisLayer1ByCollection.get(ids.AppBskyFeedPost) ?? [] + const postsLayer1 = await this.feed.getPosts( + [...postUrisLayer1, ...additionalRootUris], + includeTakedowns, + ) + // second level embeds, ignoring any additional root uris we mixed-in to the previous layer + const urisLayer2 = nestedRecordUrisFromPosts(postsLayer1, postUrisLayer1) + const urisLayer2ByCollection = urisByCollection(urisLayer2) + const postUrisLayer2 = urisLayer2ByCollection.get(ids.AppBskyFeedPost) ?? [] + const threadRootUris = new Set() + for (const [uri, post] of postsLayer0) { + if (post) { + threadRootUris.add(rootUriFromPost(post) ?? uri) + } + } + const [postsLayer2, threadgates] = await Promise.all([ + this.feed.getPosts(postUrisLayer2, includeTakedowns), + this.feed.getThreadgatesForPosts([...threadRootUris.values()]), + ]) + // collect list/feedgen embeds, lists in threadgates, post record hydration + const gateListUris = getListUrisFromGates(threadgates) + const nestedListUris = [ + ...(urisLayer1ByCollection.get(ids.AppBskyGraphList) ?? []), + ...(urisLayer2ByCollection.get(ids.AppBskyGraphList) ?? []), + ] + const nestedFeedGenUris = [ + ...(urisLayer1ByCollection.get(ids.AppBskyFeedGenerator) ?? []), + ...(urisLayer2ByCollection.get(ids.AppBskyFeedGenerator) ?? []), + ] + const posts = + mergeManyMaps(postsLayer0, postsLayer1, postsLayer2) ?? postsLayer0 + const allPostUris = [...posts.keys()] + const [ + postAggs, + postViewers, + labels, + postBlocks, + profileState, + listState, + feedGenState, + ] = await Promise.all([ + this.feed.getPostAggregates(refs), + viewer ? this.feed.getPostViewerStates(refs, viewer) : undefined, + this.label.getLabelsForSubjects(allPostUris), + this.hydratePostBlocks(posts), + this.hydrateProfiles( + allPostUris.map(didFromUri), + viewer, + includeTakedowns, + ), + this.hydrateLists([...nestedListUris, ...gateListUris], viewer), + this.hydrateFeedGens(nestedFeedGenUris, viewer), + ]) + // combine all hydration state + return mergeManyStates(profileState, listState, feedGenState, { + posts, + postAggs, + postViewers, + postBlocks, + labels, + threadgates, + viewer, + }) + } + + private async hydratePostBlocks(posts: Posts): Promise { + const postBlocks = new HydrationMap() + const postBlocksPairs = new Map() + const relationships: RelationshipPair[] = [] + for (const [uri, item] of posts) { + if (!item) continue + const post = item.record + const creator = didFromUri(uri) + const postBlockPairs: PostBlockPairs = {} + postBlocksPairs.set(uri, postBlockPairs) + // 3p block for replies + const parentUri = post.reply?.parent.uri + const parentDid = parentUri && didFromUri(parentUri) + if (parentDid) { + const pair: RelationshipPair = [creator, parentDid] + relationships.push(pair) + postBlockPairs.reply = pair + } + // 3p block for record embeds + for (const embedUri of nestedRecordUris(post)) { + const pair: RelationshipPair = [creator, didFromUri(embedUri)] + relationships.push(pair) + postBlockPairs.embed = pair + } + } + // replace embed/reply pairs with block state + const blocks = await this.graph.getBidirectionalBlocks(relationships) + for (const [uri, { embed, reply }] of postBlocksPairs) { + postBlocks.set(uri, { + embed: !!embed && blocks.isBlocked(...embed), + reply: !!reply && blocks.isBlocked(...reply), + }) + } + return postBlocks + } + + // app.bsky.feed.defs#feedViewPost + // - post (+ replies) + // - profile + // - list basic + // - list + // - profile + // - list basic + // - feedgen + // - profile + // - list basic + // - repost + // - profile + // - list basic + // - post + // - ... + async hydrateFeedItems( + items: FeedItem[], + viewer: string | null, + includeTakedowns = false, + ): Promise { + const postUris = items.map((item) => item.post.uri) + const repostUris = mapDefined(items, (item) => item.repost?.uri) + const [posts, reposts, repostProfileState] = await Promise.all([ + this.feed.getPosts(postUris, includeTakedowns), + this.feed.getReposts(repostUris, includeTakedowns), + this.hydrateProfiles( + repostUris.map(didFromUri), + viewer, + includeTakedowns, + ), + ]) + const postAndReplyRefs: ItemRef[] = [] + posts.forEach((post, uri) => { + if (!post) return + postAndReplyRefs.push({ uri, cid: post.cid }) + if (post.record.reply) { + postAndReplyRefs.push(post.record.reply.root, post.record.reply.parent) + } + }) + const postState = await this.hydratePosts( + postAndReplyRefs, + viewer, + includeTakedowns, + { posts }, + ) + return mergeManyStates(postState, repostProfileState, { + reposts, + viewer, + }) + } + + // app.bsky.feed.defs#threadViewPost + // - post + // - profile + // - list basic + // - list + // - profile + // - list basic + // - feedgen + // - profile + // - list basic + async hydrateThreadPosts( + refs: ItemRef[], + viewer: string | null, + ): Promise { + return this.hydratePosts(refs, viewer) + } + + // app.bsky.feed.defs#generatorView + // - feedgen + // - profile + // - list basic + async hydrateFeedGens( + uris: string[], // @TODO any way to get refs here? + viewer: string | null, + ): Promise { + const [feedgens, feedgenAggs, feedgenViewers, profileState] = + await Promise.all([ + this.feed.getFeedGens(uris), + this.feed.getFeedGenAggregates(uris.map((uri) => ({ uri }))), + viewer ? this.feed.getFeedGenViewerStates(uris, viewer) : undefined, + this.hydrateProfiles(uris.map(didFromUri), viewer), + ]) + return mergeStates(profileState, { + feedgens, + feedgenAggs, + feedgenViewers, + viewer, + }) + } + + // app.bsky.feed.getLikes#like + // - like + // - profile + // - list basic + async hydrateLikes( + uris: string[], + viewer: string | null, + ): Promise { + const [likes, profileState] = await Promise.all([ + this.feed.getLikes(uris), + this.hydrateProfiles(uris.map(didFromUri), viewer), + ]) + return mergeStates(profileState, { likes, viewer }) + } + + // app.bsky.feed.getRepostedBy#repostedBy + // - repost + // - profile + // - list basic + async hydrateReposts(uris: string[], viewer: string | null) { + const [reposts, profileState] = await Promise.all([ + this.feed.getReposts(uris), + this.hydrateProfiles(uris.map(didFromUri), viewer), + ]) + return mergeStates(profileState, { reposts, viewer }) + } + + // app.bsky.notification.listNotifications#notification + // - notification + // - profile + // - list basic + async hydrateNotifications( + notifs: Notification[], + viewer: string | null, + ): Promise { + const uris = notifs.map((notif) => notif.uri) + const collections = urisByCollection(uris) + const postUris = collections.get(ids.AppBskyFeedPost) ?? [] + const likeUris = collections.get(ids.AppBskyFeedLike) ?? [] + const repostUris = collections.get(ids.AppBskyFeedRepost) ?? [] + const followUris = collections.get(ids.AppBskyGraphFollow) ?? [] + const [posts, likes, reposts, follows, labels, profileState] = + await Promise.all([ + this.feed.getPosts(postUris), // reason: mention, reply, quote + this.feed.getLikes(likeUris), // reason: like + this.feed.getReposts(repostUris), // reason: repost + this.graph.getFollows(followUris), // reason: follow + this.label.getLabelsForSubjects(uris), + this.hydrateProfiles(uris.map(didFromUri), viewer), + ]) + return mergeStates(profileState, { + posts, + likes, + reposts, + follows, + labels, + viewer, + }) + } + + // provides partial hydration state withing getFollows / getFollowers, mainly for applying rules + async hydrateFollows(uris: string[]): Promise { + const follows = await this.graph.getFollows(uris) + const pairs: RelationshipPair[] = [] + for (const [uri, follow] of follows) { + if (follow) { + pairs.push([didFromUri(uri), follow.record.subject]) + } + } + const blocks = await this.graph.getBidirectionalBlocks(pairs) + const followBlocks = new HydrationMap() + for (const [uri, follow] of follows) { + if (follow) { + followBlocks.set( + uri, + blocks.isBlocked(didFromUri(uri), follow.record.subject), + ) + } else { + followBlocks.set(uri, null) + } + } + return { follows, followBlocks } + } + + // ad-hoc record hydration + // in com.atproto.repo.getRecord + async getRecord( + uri: string, + includeTakedowns = false, + ): Promise> | undefined> { + const parsed = new AtUri(uri) + const collection = parsed.collection + if (collection === ids.AppBskyFeedPost) { + return ( + (await this.feed.getPosts([uri], includeTakedowns)).get(uri) ?? + undefined + ) + } else if (collection === ids.AppBskyFeedRepost) { + return ( + (await this.feed.getReposts([uri], includeTakedowns)).get(uri) ?? + undefined + ) + } else if (collection === ids.AppBskyFeedLike) { + return ( + (await this.feed.getLikes([uri], includeTakedowns)).get(uri) ?? + undefined + ) + } else if (collection === ids.AppBskyGraphFollow) { + return ( + (await this.graph.getFollows([uri], includeTakedowns)).get(uri) ?? + undefined + ) + } else if (collection === ids.AppBskyGraphList) { + return ( + (await this.graph.getLists([uri], includeTakedowns)).get(uri) ?? + undefined + ) + } else if (collection === ids.AppBskyGraphListitem) { + return ( + (await this.graph.getListItems([uri], includeTakedowns)).get(uri) ?? + undefined + ) + } else if (collection === ids.AppBskyGraphBlock) { + return ( + (await this.graph.getBlocks([uri], includeTakedowns)).get(uri) ?? + undefined + ) + } else if (collection === ids.AppBskyFeedGenerator) { + return ( + (await this.feed.getFeedGens([uri], includeTakedowns)).get(uri) ?? + undefined + ) + } else if (collection === ids.AppBskyActorProfile) { + const did = parsed.hostname + const actor = (await this.actor.getActors([did], includeTakedowns)).get( + did, + ) + if (!actor?.profile || !actor?.profileCid) return undefined + return { + record: actor.profile, + cid: actor.profileCid, + sortedAt: actor.sortedAt ?? new Date(0), // @NOTE will be present since profile record is present + takedownRef: actor.profileTakedownRef, + } + } + } +} + +const listUrisFromProfileViewer = (item: ProfileViewerState | null) => { + const listUris: string[] = [] + if (item?.mutedByList) { + listUris.push(item.mutedByList) + } + if (item?.blockingByList) { + listUris.push(item.blockingByList) + } + // blocked-by list does not appear in views, but will be used to evaluate the existence of a block between users. + if (item?.blockedByList) { + listUris.push(item.blockedByList) + } + return listUris +} + +const removeNonModListsFromProfileViewer = ( + item: ProfileViewerState | null, + state: HydrationState, +) => { + if (!isModList(item?.mutedByList, state)) { + delete item?.mutedByList + } + if (!isModList(item?.blockingByList, state)) { + delete item?.blockingByList + } + if (!isModList(item?.blockedByList, state)) { + delete item?.blockedByList + } +} + +const isModList = ( + listUri: string | undefined, + state: HydrationState, +): boolean => { + if (!listUri) return false + const list = state.lists?.get(listUri) + return list?.record.purpose === 'app.bsky.graph.defs#modlist' +} + +const labelSubjectsForDid = (dids: string[]) => { + return [ + ...dids, + ...dids.map((did) => + AtUri.make(did, ids.AppBskyActorProfile, 'self').toString(), + ), + ] +} + +const rootUrisFromPosts = (posts: Posts): string[] => { + const uris: string[] = [] + for (const item of posts.values()) { + const rootUri = item && rootUriFromPost(item) + if (rootUri) { + uris.push(rootUri) + } + } + return uris +} + +const rootUriFromPost = (post: Post): string | undefined => { + return post.record.reply?.root.uri +} + +const nestedRecordUrisFromPosts = ( + posts: Posts, + fromUris?: string[], +): string[] => { + const uris: string[] = [] + const postUris = fromUris ?? posts.keys() + for (const uri of postUris) { + const item = posts.get(uri) + if (item) { + uris.push(...nestedRecordUris(item.record)) + } + } + return uris +} + +const nestedRecordUris = (post: Post['record']): string[] => { + const uris: string[] = [] + if (!post?.embed) return uris + if (isEmbedRecord(post.embed)) { + uris.push(post.embed.record.uri) + } else if (isEmbedRecordWithMedia(post.embed)) { + uris.push(post.embed.record.record.uri) + } + return uris +} + +const getListUrisFromGates = (gates: Threadgates) => { + const uris: string[] = [] + for (const gate of gates.values()) { + const listRules = gate?.record.allow?.filter(isListRule) ?? [] + for (const rule of listRules) { + uris.push(rule.list) + } + } + return uris +} + +export const mergeStates = ( + stateA: HydrationState, + stateB: HydrationState, +): HydrationState => { + assert( + !stateA.viewer || !stateB.viewer || stateA.viewer === stateB.viewer, + 'incompatible viewers', + ) + return { + viewer: stateA.viewer ?? stateB.viewer, + actors: mergeMaps(stateA.actors, stateB.actors), + profileAggs: mergeMaps(stateA.profileAggs, stateB.profileAggs), + profileViewers: mergeMaps(stateA.profileViewers, stateB.profileViewers), + posts: mergeMaps(stateA.posts, stateB.posts), + postAggs: mergeMaps(stateA.postAggs, stateB.postAggs), + postViewers: mergeMaps(stateA.postViewers, stateB.postViewers), + postBlocks: mergeMaps(stateA.postBlocks, stateB.postBlocks), + reposts: mergeMaps(stateA.reposts, stateB.reposts), + follows: mergeMaps(stateA.follows, stateB.follows), + followBlocks: mergeMaps(stateA.followBlocks, stateB.followBlocks), + threadgates: mergeMaps(stateA.threadgates, stateB.threadgates), + lists: mergeMaps(stateA.lists, stateB.lists), + listViewers: mergeMaps(stateA.listViewers, stateB.listViewers), + listItems: mergeMaps(stateA.listItems, stateB.listItems), + likes: mergeMaps(stateA.likes, stateB.likes), + labels: mergeMaps(stateA.labels, stateB.labels), + feedgens: mergeMaps(stateA.feedgens, stateB.feedgens), + feedgenAggs: mergeMaps(stateA.feedgenAggs, stateB.feedgenAggs), + feedgenViewers: mergeMaps(stateA.feedgenViewers, stateB.feedgenViewers), + } +} + +const mergeMaps = ( + mapA?: HydrationMap, + mapB?: HydrationMap, +): HydrationMap | undefined => { + if (!mapA) return mapB + if (!mapB) return mapA + return mapA.merge(mapB) +} + +const mergeManyStates = (...states: HydrationState[]) => { + return states.reduce(mergeStates, {} as HydrationState) +} + +const mergeManyMaps = (...maps: HydrationMap[]) => { + return maps.reduce(mergeMaps, undefined as HydrationMap | undefined) +} diff --git a/packages/bsky/src/hydration/label.ts b/packages/bsky/src/hydration/label.ts new file mode 100644 index 00000000000..352c9ed4059 --- /dev/null +++ b/packages/bsky/src/hydration/label.ts @@ -0,0 +1,36 @@ +import { DataPlaneClient } from '../data-plane/client' +import { Label } from '../lexicon/types/com/atproto/label/defs' +import { HydrationMap, parseJsonBytes } from './util' + +export type { Label } from '../lexicon/types/com/atproto/label/defs' + +export type Labels = HydrationMap + +export class LabelHydrator { + constructor( + public dataplane: DataPlaneClient, + public opts?: { labelsFromIssuerDids?: string[] }, + ) {} + + async getLabelsForSubjects( + subjects: string[], + issuers?: string[], + ): Promise { + issuers = ([] as string[]) + .concat(issuers ?? []) + .concat(this.opts?.labelsFromIssuerDids ?? []) + if (!subjects.length || !issuers.length) return new HydrationMap() + const res = await this.dataplane.getLabels({ subjects, issuers }) + return res.labels.reduce((acc, cur) => { + const label = parseJsonBytes(cur) as Label | undefined + if (!label || label.neg) return acc + const entry = acc.get(label.uri) + if (entry) { + entry.push(label) + } else { + acc.set(label.uri, [label]) + } + return acc + }, new HydrationMap()) + } +} diff --git a/packages/bsky/src/hydration/util.ts b/packages/bsky/src/hydration/util.ts new file mode 100644 index 00000000000..675c0e9f3d0 --- /dev/null +++ b/packages/bsky/src/hydration/util.ts @@ -0,0 +1,125 @@ +import { AtUri } from '@atproto/syntax' +import { jsonToLex } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import * as ui8 from 'uint8arrays' +import { lexicons } from '../lexicon/lexicons' +import { Record } from '../proto/bsky_pb' + +export class HydrationMap extends Map { + merge(map: HydrationMap): HydrationMap { + map.forEach((val, key) => { + this.set(key, val) + }) + return this + } +} + +export type RecordInfo = { + record: T + cid: string + sortedAt: Date + takedownRef: string | undefined +} + +export const parseRecord = ( + entry: Record, + includeTakedowns: boolean, +): RecordInfo | undefined => { + if (!includeTakedowns && entry.takenDown) { + return undefined + } + const record = parseRecordBytes(entry.record) + const cid = entry.cid + const sortedAt = entry.sortedAt?.toDate() ?? new Date(0) + if (!record || !cid) return + if (!isValidRecord(record)) { + return + } + return { + record, + cid, + sortedAt, + takedownRef: safeTakedownRef(entry), + } +} + +const isValidRecord = (json: unknown) => { + const lexRecord = jsonToLex(json) + if (typeof lexRecord?.['$type'] !== 'string') { + return false + } + try { + lexicons.assertValidRecord(lexRecord['$type'], lexRecord) + return true + } catch { + return false + } +} + +// @NOTE not parsed into lex format, so will not match lexicon record types on CID and blob values. +export const parseRecordBytes = ( + bytes: Uint8Array | undefined, +): T | undefined => { + return parseJsonBytes(bytes) as T +} + +export const parseJsonBytes = ( + bytes: Uint8Array | undefined, +): JSON | undefined => { + if (!bytes || bytes.byteLength === 0) return + const parsed = JSON.parse(ui8.toString(bytes, 'utf8')) + return parsed ?? undefined +} + +export const parseString = (str: string | undefined): string | undefined => { + return str && str.length > 0 ? str : undefined +} + +export const parseCid = (cidStr: string | undefined): CID | undefined => { + if (!cidStr || cidStr.length === 0) return + try { + return CID.parse(cidStr) + } catch { + return + } +} + +export const didFromUri = (uri: string) => { + return new AtUri(uri).hostname +} + +export const urisByCollection = (uris: string[]): Map => { + const result = new Map() + for (const uri of uris) { + const collection = new AtUri(uri).collection + const items = result.get(collection) ?? [] + items.push(uri) + result.set(collection, items) + } + return result +} + +export const split = ( + items: T[], + predicate: (item: T) => boolean, +): [T[], T[]] => { + const yes: T[] = [] + const no: T[] = [] + for (const item of items) { + if (predicate(item)) { + yes.push(item) + } else { + no.push(item) + } + } + return [yes, no] +} + +export const safeTakedownRef = (obj?: { + takenDown: boolean + takedownRef: string +}): string | undefined => { + if (!obj) return + if (obj.takedownRef) return obj.takedownRef + if (obj.takenDown) return 'BSKY-TAKEDOWN-UNKNOWN' +} diff --git a/packages/bsky/src/image/uri.ts b/packages/bsky/src/image/uri.ts index 5e288e29d10..3de769178ed 100644 --- a/packages/bsky/src/image/uri.ts +++ b/packages/bsky/src/image/uri.ts @@ -1,4 +1,3 @@ -import { CID } from 'multiformats/cid' import { Options } from './util' // @NOTE if there are any additions here, ensure to include them on ImageUriBuilder.presets @@ -20,7 +19,7 @@ export class ImageUriBuilder { 'feed_fullsize', ] - getPresetUri(id: ImagePreset, did: string, cid: string | CID): string { + getPresetUri(id: ImagePreset, did: string, cid: string): string { const options = presets[id] if (!options) { throw new Error(`Unrecognized requested common uri type: ${id}`) @@ -30,14 +29,14 @@ export class ImageUriBuilder { ImageUriBuilder.getPath({ preset: id, did, - cid: typeof cid === 'string' ? CID.parse(cid) : cid, + cid, }) ) } static getPath(opts: { preset: ImagePreset } & BlobLocation) { const { format } = presets[opts.preset] - return `/${opts.preset}/plain/${opts.did}/${opts.cid.toString()}@${format}` + return `/${opts.preset}/plain/${opts.did}/${opts.cid}@${format}` } static getOptions( @@ -59,14 +58,14 @@ export class ImageUriBuilder { return { ...presets[preset], did, - cid: CID.parse(cid), + cid, preset, format, } } } -type BlobLocation = { cid: CID; did: string } +type BlobLocation = { cid: string; did: string } export class BadPathError extends Error {} diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index 8a6bb6592e6..a968d85b584 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -5,53 +5,36 @@ import events from 'events' import { createHttpTerminator, HttpTerminator } from 'http-terminator' import cors from 'cors' import compression from 'compression' +import AtpAgent from '@atproto/api' import { IdResolver } from '@atproto/identity' -import { - RateLimiter, - RateLimiterOpts, - Options as XrpcServerOptions, -} from '@atproto/xrpc-server' -import { MINUTE } from '@atproto/common' import API, { health, wellKnown, blobResolver } from './api' -import { DatabaseCoordinator } from './db' import * as error from './error' -import { dbLogger, loggerMiddleware } from './logger' +import { loggerMiddleware } from './logger' import { ServerConfig } from './config' import { createServer } from './lexicon' import { ImageUriBuilder } from './image/uri' import { BlobDiskCache, ImageProcessingServer } from './image/server' -import { createServices } from './services' import AppContext from './context' -import DidRedisCache from './did-cache' -import { - ImageInvalidator, - ImageProcessingServerInvalidator, -} from './image/invalidator' -import { BackgroundQueue } from './background' -import { AtpAgent } from '@atproto/api' import { Keypair } from '@atproto/crypto' -import { Redis } from './redis' +import { createDataPlaneClient } from './data-plane/client' +import { Hydrator } from './hydration/hydrator' +import { Views } from './views' import { AuthVerifier } from './auth-verifier' import { authWithApiKey as bsyncAuth, createBsyncClient } from './bsync' import { authWithApiKey as courierAuth, createCourierClient } from './courier' +export * from './data-plane' export type { ServerConfigValues } from './config' export { ServerConfig } from './config' -export { Database, PrimaryDatabase, DatabaseCoordinator } from './db' +export { Database } from './data-plane/server/db' export { Redis } from './redis' -export { ViewMaintainer } from './db/views' export { AppContext } from './context' -export type { ImageInvalidator } from './image/invalidator' -export * from './daemon' -export * from './indexer' -export * from './ingester' export class BskyAppView { public ctx: AppContext public app: express.Application public server?: http.Server private terminator?: HttpTerminator - private dbStatsInterval: NodeJS.Timer constructor(opts: { ctx: AppContext; app: express.Application }) { this.ctx = opts.ctx @@ -59,151 +42,89 @@ export class BskyAppView { } static create(opts: { - db: DatabaseCoordinator - redis: Redis config: ServerConfig signingKey: Keypair - imgInvalidator?: ImageInvalidator }): BskyAppView { - const { db, redis, config, signingKey } = opts - let maybeImgInvalidator = opts.imgInvalidator + const { config, signingKey } = opts const app = express() - app.set('trust proxy', true) app.use(cors()) app.use(loggerMiddleware) app.use(compression()) - const didCache = new DidRedisCache(redis.withNamespace('did-doc'), { - staleTTL: config.didCacheStaleTTL, - maxTTL: config.didCacheMaxTTL, - }) - + // used solely for handle resolution: identity lookups occur on dataplane const idResolver = new IdResolver({ plcUrl: config.didPlcUrl, - didCache, backupNameservers: config.handleResolveNameservers, }) const imgUriBuilder = new ImageUriBuilder( - config.imgUriEndpoint || `${config.publicUrl}/img`, + config.cdnUrl || `${config.publicUrl}/img`, ) let imgProcessingServer: ImageProcessingServer | undefined - if (!config.imgUriEndpoint) { + if (!config.cdnUrl) { const imgProcessingCache = new BlobDiskCache(config.blobCacheLocation) imgProcessingServer = new ImageProcessingServer( config, imgProcessingCache, ) - maybeImgInvalidator ??= new ImageProcessingServerInvalidator( - imgProcessingCache, - ) - } - - let imgInvalidator: ImageInvalidator - if (maybeImgInvalidator) { - imgInvalidator = maybeImgInvalidator - } else { - throw new Error('Missing appview image invalidator') } - const backgroundQueue = new BackgroundQueue(db.getPrimary()) - - const searchAgent = config.searchEndpoint - ? new AtpAgent({ service: config.searchEndpoint }) + const searchAgent = config.searchUrl + ? new AtpAgent({ service: config.searchUrl }) : undefined + const dataplane = createDataPlaneClient(config.dataplaneUrls, { + httpVersion: config.dataplaneHttpVersion, + rejectUnauthorized: !config.dataplaneIgnoreBadTls, + }) + const hydrator = new Hydrator(dataplane, { + labelsFromIssuerDids: config.labelsFromIssuerDids, + }) + const views = new Views(imgUriBuilder) - const services = createServices({ - imgUriBuilder, - imgInvalidator, - labelCacheOpts: { - redis: redis.withNamespace('label'), - staleTTL: config.labelCacheStaleTTL, - maxTTL: config.labelCacheMaxTTL, - }, + const bsyncClient = createBsyncClient({ + baseUrl: config.bsyncUrl, + httpVersion: config.bsyncHttpVersion ?? '2', + nodeOptions: { rejectUnauthorized: !config.bsyncIgnoreBadTls }, + interceptors: config.bsyncApiKey ? [bsyncAuth(config.bsyncApiKey)] : [], + }) + + const courierClient = createCourierClient({ + baseUrl: config.courierUrl, + httpVersion: config.courierHttpVersion ?? '2', + nodeOptions: { rejectUnauthorized: !config.courierIgnoreBadTls }, + interceptors: config.courierApiKey + ? [courierAuth(config.courierApiKey)] + : [], }) - const authVerifier = new AuthVerifier(idResolver, { + const authVerifier = new AuthVerifier(dataplane, { ownDid: config.serverDid, adminDid: config.modServiceDid, - adminPass: config.adminPassword, - moderatorPass: config.moderatorPassword, - triagePass: config.triagePassword, + adminPasses: config.adminPasswords, }) - const bsyncClient = config.bsyncUrl - ? createBsyncClient({ - baseUrl: config.bsyncUrl, - httpVersion: config.bsyncHttpVersion ?? '2', - nodeOptions: { rejectUnauthorized: !config.bsyncIgnoreBadTls }, - interceptors: config.bsyncApiKey - ? [bsyncAuth(config.bsyncApiKey)] - : [], - }) - : undefined - - const courierClient = config.courierUrl - ? createCourierClient({ - baseUrl: config.courierUrl, - httpVersion: config.courierHttpVersion ?? '2', - nodeOptions: { rejectUnauthorized: !config.courierIgnoreBadTls }, - interceptors: config.courierApiKey - ? [courierAuth(config.courierApiKey)] - : [], - }) - : undefined - const ctx = new AppContext({ - db, cfg: config, - services, - imgUriBuilder, + dataplane, + searchAgent, + hydrator, + views, signingKey, idResolver, - didCache, - redis, - backgroundQueue, - searchAgent, bsyncClient, courierClient, authVerifier, }) - const xrpcOpts: XrpcServerOptions = { + let server = createServer({ validateResponse: config.debugMode, payload: { jsonLimit: 100 * 1024, // 100kb textLimit: 100 * 1024, // 100kb blobLimit: 5 * 1024 * 1024, // 5mb }, - } - if (config.rateLimitsEnabled) { - const rlCreator = (opts: RateLimiterOpts) => - RateLimiter.redis(redis.driver, { - bypassSecret: config.rateLimitBypassKey, - bypassIps: config.rateLimitBypassIps, - ...opts, - }) - xrpcOpts['rateLimits'] = { - creator: rlCreator, - global: [ - { - name: 'global-unauthed-ip', - durationMs: 5 * MINUTE, - points: 3000, - calcKey: (ctx) => (ctx.auth ? null : ctx.req.ip), - }, - { - name: 'global-authed-did', - durationMs: 5 * MINUTE, - points: 3000, - calcKey: (ctx) => ctx.auth?.credentials?.did ?? null, - }, - ], - } - } - - let server = createServer(xrpcOpts) + }) server = API(server, ctx) @@ -220,38 +141,6 @@ export class BskyAppView { } async start(): Promise { - const { db, backgroundQueue } = this.ctx - const primary = db.getPrimary() - const replicas = db.getReplicas() - this.dbStatsInterval = setInterval(() => { - dbLogger.info( - { - idleCount: replicas.reduce( - (tot, replica) => tot + replica.pool.idleCount, - 0, - ), - totalCount: replicas.reduce( - (tot, replica) => tot + replica.pool.totalCount, - 0, - ), - waitingCount: replicas.reduce( - (tot, replica) => tot + replica.pool.waitingCount, - 0, - ), - primaryIdleCount: primary.pool.idleCount, - primaryTotalCount: primary.pool.totalCount, - primaryWaitingCount: primary.pool.waitingCount, - }, - 'db pool stats', - ) - dbLogger.info( - { - runningCount: backgroundQueue.queue.pending, - waitingCount: backgroundQueue.queue.size, - }, - 'background queue stats', - ) - }, 10000) const server = this.app.listen(this.ctx.cfg.port) this.server = server server.keepAliveTimeout = 90000 @@ -262,13 +151,8 @@ export class BskyAppView { return server } - async destroy(opts?: { skipDb: boolean; skipRedis: boolean }): Promise { - await this.ctx.didCache.destroy() + async destroy(): Promise { await this.terminator?.terminate() - await this.ctx.backgroundQueue.destroy() - if (!opts?.skipRedis) await this.ctx.redis.destroy() - if (!opts?.skipDb) await this.ctx.db.close() - clearInterval(this.dbStatsInterval) } } diff --git a/packages/bsky/src/indexer/config.ts b/packages/bsky/src/indexer/config.ts deleted file mode 100644 index 1a4c14ff85e..00000000000 --- a/packages/bsky/src/indexer/config.ts +++ /dev/null @@ -1,271 +0,0 @@ -import assert from 'assert' -import { DAY, HOUR, parseIntWithFallback } from '@atproto/common' - -export interface IndexerConfigValues { - version: string - serverDid: string - dbPostgresUrl: string - dbPostgresSchema?: string - redisHost?: string // either set redis host, or both sentinel name and hosts - redisSentinelName?: string - redisSentinelHosts?: string[] - redisPassword?: string - didPlcUrl: string - didCacheStaleTTL: number - didCacheMaxTTL: number - handleResolveNameservers?: string[] - hiveApiKey?: string - imgUriEndpoint?: string - labelerKeywords: Record - moderationPushUrl: string - courierUrl?: string - courierApiKey?: string - courierHttpVersion?: '1.1' | '2' - courierIgnoreBadTls?: boolean - indexerConcurrency?: number - indexerPartitionIds: number[] - indexerPartitionBatchSize?: number - indexerSubLockId?: number - indexerPort?: number - ingesterPartitionCount: number - indexerNamespace?: string - pushNotificationEndpoint?: string -} - -export class IndexerConfig { - constructor(private cfg: IndexerConfigValues) {} - - static readEnv(overrides?: Partial) { - const version = process.env.BSKY_VERSION || '0.0.0' - const serverDid = process.env.SERVER_DID || 'did:example:test' - const dbPostgresUrl = - overrides?.dbPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL - const dbPostgresSchema = - overrides?.dbPostgresSchema || process.env.DB_POSTGRES_SCHEMA - const redisHost = - overrides?.redisHost || process.env.REDIS_HOST || undefined - const redisSentinelName = - overrides?.redisSentinelName || - process.env.REDIS_SENTINEL_NAME || - undefined - const redisSentinelHosts = - overrides?.redisSentinelHosts || - (process.env.REDIS_SENTINEL_HOSTS - ? process.env.REDIS_SENTINEL_HOSTS.split(',') - : []) - const redisPassword = - overrides?.redisPassword || process.env.REDIS_PASSWORD || undefined - const didPlcUrl = process.env.DID_PLC_URL || 'http://localhost:2582' - const didCacheStaleTTL = parseIntWithFallback( - process.env.DID_CACHE_STALE_TTL, - HOUR, - ) - const didCacheMaxTTL = parseIntWithFallback( - process.env.DID_CACHE_MAX_TTL, - DAY, - ) - const handleResolveNameservers = process.env.HANDLE_RESOLVE_NAMESERVERS - ? process.env.HANDLE_RESOLVE_NAMESERVERS.split(',') - : [] - const moderationPushUrl = - overrides?.moderationPushUrl || - process.env.MODERATION_PUSH_URL || - undefined - assert(moderationPushUrl) - const courierUrl = - overrides?.courierUrl || process.env.BSKY_COURIER_URL || undefined - const courierApiKey = - overrides?.courierApiKey || process.env.BSKY_COURIER_API_KEY || undefined - const courierHttpVersion = - overrides?.courierHttpVersion || - process.env.BSKY_COURIER_HTTP_VERSION || - '2' - const courierIgnoreBadTls = - overrides?.courierIgnoreBadTls || - process.env.BSKY_COURIER_IGNORE_BAD_TLS === 'true' - assert(courierHttpVersion === '1.1' || courierHttpVersion === '2') - const hiveApiKey = process.env.HIVE_API_KEY || undefined - const imgUriEndpoint = process.env.IMG_URI_ENDPOINT - const indexerPartitionIds = - overrides?.indexerPartitionIds || - (process.env.INDEXER_PARTITION_IDS - ? process.env.INDEXER_PARTITION_IDS.split(',').map((n) => - parseInt(n, 10), - ) - : []) - const indexerPartitionBatchSize = maybeParseInt( - process.env.INDEXER_PARTITION_BATCH_SIZE, - ) - const indexerConcurrency = maybeParseInt(process.env.INDEXER_CONCURRENCY) - const indexerNamespace = overrides?.indexerNamespace - const indexerSubLockId = maybeParseInt(process.env.INDEXER_SUB_LOCK_ID) - const indexerPort = maybeParseInt(process.env.INDEXER_PORT) - const ingesterPartitionCount = - maybeParseInt(process.env.INGESTER_PARTITION_COUNT) ?? 64 - const labelerKeywords = {} - const pushNotificationEndpoint = process.env.PUSH_NOTIFICATION_ENDPOINT - assert(dbPostgresUrl) - assert(redisHost || (redisSentinelName && redisSentinelHosts?.length)) - assert(indexerPartitionIds.length > 0) - return new IndexerConfig({ - version, - serverDid, - dbPostgresUrl, - dbPostgresSchema, - redisHost, - redisSentinelName, - redisSentinelHosts, - redisPassword, - didPlcUrl, - didCacheStaleTTL, - didCacheMaxTTL, - handleResolveNameservers, - moderationPushUrl, - courierUrl, - courierApiKey, - courierHttpVersion, - courierIgnoreBadTls, - hiveApiKey, - imgUriEndpoint, - indexerPartitionIds, - indexerConcurrency, - indexerPartitionBatchSize, - indexerNamespace, - indexerSubLockId, - indexerPort, - ingesterPartitionCount, - labelerKeywords, - pushNotificationEndpoint, - ...stripUndefineds(overrides ?? {}), - }) - } - - get version() { - return this.cfg.version - } - - get serverDid() { - return this.cfg.serverDid - } - - get dbPostgresUrl() { - return this.cfg.dbPostgresUrl - } - - get dbPostgresSchema() { - return this.cfg.dbPostgresSchema - } - - get redisHost() { - return this.cfg.redisHost - } - - get redisSentinelName() { - return this.cfg.redisSentinelName - } - - get redisSentinelHosts() { - return this.cfg.redisSentinelHosts - } - - get redisPassword() { - return this.cfg.redisPassword - } - - get didPlcUrl() { - return this.cfg.didPlcUrl - } - - get didCacheStaleTTL() { - return this.cfg.didCacheStaleTTL - } - - get didCacheMaxTTL() { - return this.cfg.didCacheMaxTTL - } - - get handleResolveNameservers() { - return this.cfg.handleResolveNameservers - } - - get moderationPushUrl() { - return this.cfg.moderationPushUrl - } - - get courierUrl() { - return this.cfg.courierUrl - } - - get courierApiKey() { - return this.cfg.courierApiKey - } - - get courierHttpVersion() { - return this.cfg.courierHttpVersion - } - - get courierIgnoreBadTls() { - return this.cfg.courierIgnoreBadTls - } - - get hiveApiKey() { - return this.cfg.hiveApiKey - } - - get imgUriEndpoint() { - return this.cfg.imgUriEndpoint - } - - get indexerConcurrency() { - return this.cfg.indexerConcurrency - } - - get indexerPartitionIds() { - return this.cfg.indexerPartitionIds - } - - get indexerPartitionBatchSize() { - return this.cfg.indexerPartitionBatchSize - } - - get indexerNamespace() { - return this.cfg.indexerNamespace - } - - get indexerSubLockId() { - return this.cfg.indexerSubLockId - } - - get indexerPort() { - return this.cfg.indexerPort - } - - get ingesterPartitionCount() { - return this.cfg.ingesterPartitionCount - } - - get labelerKeywords() { - return this.cfg.labelerKeywords - } - - get pushNotificationEndpoint() { - return this.cfg.pushNotificationEndpoint - } -} - -function stripUndefineds( - obj: Record, -): Record { - const result = {} - Object.entries(obj).forEach(([key, val]) => { - if (val !== undefined) { - result[key] = val - } - }) - return result -} - -function maybeParseInt(str) { - const parsed = parseInt(str) - return isNaN(parsed) ? undefined : parsed -} diff --git a/packages/bsky/src/indexer/context.ts b/packages/bsky/src/indexer/context.ts deleted file mode 100644 index a4c1f1f2ea0..00000000000 --- a/packages/bsky/src/indexer/context.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { IdResolver } from '@atproto/identity' -import { PrimaryDatabase } from '../db' -import { IndexerConfig } from './config' -import { Services } from './services' -import { BackgroundQueue } from '../background' -import DidSqlCache from '../did-cache' -import { Redis } from '../redis' -import { AutoModerator } from '../auto-moderator' -import { NotificationServer } from '../notifications' - -export class IndexerContext { - constructor( - private opts: { - db: PrimaryDatabase - redis: Redis - redisCache: Redis - cfg: IndexerConfig - services: Services - idResolver: IdResolver - didCache: DidSqlCache - backgroundQueue: BackgroundQueue - autoMod: AutoModerator - notifServer?: NotificationServer - }, - ) {} - - get db(): PrimaryDatabase { - return this.opts.db - } - - get redis(): Redis { - return this.opts.redis - } - - get redisCache(): Redis { - return this.opts.redisCache - } - - get cfg(): IndexerConfig { - return this.opts.cfg - } - - get services(): Services { - return this.opts.services - } - - get idResolver(): IdResolver { - return this.opts.idResolver - } - - get didCache(): DidSqlCache { - return this.opts.didCache - } - - get backgroundQueue(): BackgroundQueue { - return this.opts.backgroundQueue - } - - get autoMod(): AutoModerator { - return this.opts.autoMod - } - - get notifServer(): NotificationServer | undefined { - return this.opts.notifServer - } -} - -export default IndexerContext diff --git a/packages/bsky/src/indexer/index.ts b/packages/bsky/src/indexer/index.ts deleted file mode 100644 index 7d012304573..00000000000 --- a/packages/bsky/src/indexer/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -import express from 'express' -import { IdResolver } from '@atproto/identity' -import { BackgroundQueue } from '../background' -import { PrimaryDatabase } from '../db' -import DidRedisCache from '../did-cache' -import log from './logger' -import { dbLogger } from '../logger' -import { IndexerConfig } from './config' -import { IndexerContext } from './context' -import { createServices } from './services' -import { IndexerSubscription } from './subscription' -import { AutoModerator } from '../auto-moderator' -import { Redis } from '../redis' -import { - CourierNotificationServer, - GorushNotificationServer, - NotificationServer, -} from '../notifications' -import { CloseFn, createServer, startServer } from './server' -import { authWithApiKey as courierAuth, createCourierClient } from '../courier' - -export { IndexerConfig } from './config' -export type { IndexerConfigValues } from './config' - -export class BskyIndexer { - public ctx: IndexerContext - public sub: IndexerSubscription - public app: express.Application - private closeServer?: CloseFn - private dbStatsInterval: NodeJS.Timer - private subStatsInterval: NodeJS.Timer - - constructor(opts: { - ctx: IndexerContext - sub: IndexerSubscription - app: express.Application - }) { - this.ctx = opts.ctx - this.sub = opts.sub - this.app = opts.app - } - - static create(opts: { - db: PrimaryDatabase - redis: Redis - redisCache: Redis - cfg: IndexerConfig - }): BskyIndexer { - const { db, redis, redisCache, cfg } = opts - const didCache = new DidRedisCache(redisCache.withNamespace('did-doc'), { - staleTTL: cfg.didCacheStaleTTL, - maxTTL: cfg.didCacheMaxTTL, - }) - const idResolver = new IdResolver({ - plcUrl: cfg.didPlcUrl, - didCache, - backupNameservers: cfg.handleResolveNameservers, - }) - const backgroundQueue = new BackgroundQueue(db) - - const autoMod = new AutoModerator({ - db, - idResolver, - cfg, - backgroundQueue, - }) - - const courierClient = cfg.courierUrl - ? createCourierClient({ - baseUrl: cfg.courierUrl, - httpVersion: cfg.courierHttpVersion ?? '2', - nodeOptions: { rejectUnauthorized: !cfg.courierIgnoreBadTls }, - interceptors: cfg.courierApiKey - ? [courierAuth(cfg.courierApiKey)] - : [], - }) - : undefined - - let notifServer: NotificationServer | undefined - if (courierClient) { - notifServer = new CourierNotificationServer(db, courierClient) - } else if (cfg.pushNotificationEndpoint) { - notifServer = new GorushNotificationServer( - db, - cfg.pushNotificationEndpoint, - ) - } - - const services = createServices({ - idResolver, - autoMod, - backgroundQueue, - notifServer, - }) - const ctx = new IndexerContext({ - db, - redis, - redisCache, - cfg, - services, - idResolver, - didCache, - backgroundQueue, - autoMod, - notifServer, - }) - const sub = new IndexerSubscription(ctx, { - partitionIds: cfg.indexerPartitionIds, - partitionBatchSize: cfg.indexerPartitionBatchSize, - concurrency: cfg.indexerConcurrency, - subLockId: cfg.indexerSubLockId, - }) - - const app = createServer(sub, cfg) - - return new BskyIndexer({ ctx, sub, app }) - } - - async start() { - const { db, backgroundQueue } = this.ctx - const pool = db.pool - this.dbStatsInterval = setInterval(() => { - dbLogger.info( - { - idleCount: pool.idleCount, - totalCount: pool.totalCount, - waitingCount: pool.waitingCount, - }, - 'db pool stats', - ) - dbLogger.info( - { - runningCount: backgroundQueue.queue.pending, - waitingCount: backgroundQueue.queue.size, - }, - 'background queue stats', - ) - }, 10000) - this.subStatsInterval = setInterval(() => { - log.info( - { - processedCount: this.sub.processedCount, - runningCount: this.sub.repoQueue.main.pending, - waitingCount: this.sub.repoQueue.main.size, - }, - 'indexer stats', - ) - }, 500) - this.sub.run() - this.closeServer = startServer(this.app, this.ctx.cfg.indexerPort) - return this - } - - async destroy(opts?: { skipDb: boolean; skipRedis: true }): Promise { - if (this.closeServer) await this.closeServer() - await this.sub.destroy() - clearInterval(this.subStatsInterval) - await this.ctx.didCache.destroy() - if (!opts?.skipRedis) await this.ctx.redis.destroy() - if (!opts?.skipRedis) await this.ctx.redisCache.destroy() - if (!opts?.skipDb) await this.ctx.db.close() - clearInterval(this.dbStatsInterval) - } -} - -export default BskyIndexer diff --git a/packages/bsky/src/indexer/logger.ts b/packages/bsky/src/indexer/logger.ts deleted file mode 100644 index 45752727f99..00000000000 --- a/packages/bsky/src/indexer/logger.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { subsystemLogger } from '@atproto/common' - -const logger: ReturnType = - subsystemLogger('bsky:indexer') - -export default logger diff --git a/packages/bsky/src/indexer/server.ts b/packages/bsky/src/indexer/server.ts deleted file mode 100644 index dfafb741eb4..00000000000 --- a/packages/bsky/src/indexer/server.ts +++ /dev/null @@ -1,46 +0,0 @@ -import express from 'express' -import { IndexerSubscription } from './subscription' -import { IndexerConfig } from './config' -import { randomIntFromSeed } from '@atproto/crypto' - -export type CloseFn = () => Promise - -export const createServer = ( - sub: IndexerSubscription, - cfg: IndexerConfig, -): express.Application => { - const app = express() - app.post('/reprocess/:did', async (req, res) => { - const did = req.params.did - try { - const partition = await randomIntFromSeed(did, cfg.ingesterPartitionCount) - const supportedPartition = cfg.indexerPartitionIds.includes(partition) - if (!supportedPartition) { - return res.status(400).send(`unsupported partition: ${partition}`) - } - } catch (err) { - return res.status(500).send('could not calculate partition') - } - await sub.requestReprocess(req.params.did) - res.sendStatus(200) - }) - return app -} - -export const startServer = ( - app: express.Application, - port?: number, -): CloseFn => { - const server = app.listen(port) - return () => { - return new Promise((resolve, reject) => { - server.close((err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - } -} diff --git a/packages/bsky/src/indexer/services.ts b/packages/bsky/src/indexer/services.ts deleted file mode 100644 index df173352046..00000000000 --- a/packages/bsky/src/indexer/services.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IdResolver } from '@atproto/identity' -import { PrimaryDatabase } from '../db' -import { BackgroundQueue } from '../background' -import { IndexingService } from '../services/indexing' -import { LabelService } from '../services/label' -import { NotificationServer } from '../notifications' -import { AutoModerator } from '../auto-moderator' - -export function createServices(resources: { - idResolver: IdResolver - autoMod: AutoModerator - backgroundQueue: BackgroundQueue - notifServer?: NotificationServer -}): Services { - const { idResolver, autoMod, backgroundQueue, notifServer } = resources - return { - indexing: IndexingService.creator( - idResolver, - autoMod, - backgroundQueue, - notifServer, - ), - label: LabelService.creator(null), - } -} - -export type Services = { - indexing: FromDbPrimary - label: FromDbPrimary -} - -type FromDbPrimary = (db: PrimaryDatabase) => T diff --git a/packages/bsky/src/ingester/config.ts b/packages/bsky/src/ingester/config.ts deleted file mode 100644 index 0a3d9e79e5a..00000000000 --- a/packages/bsky/src/ingester/config.ts +++ /dev/null @@ -1,182 +0,0 @@ -import assert from 'assert' - -export interface IngesterConfigValues { - version: string - dbPostgresUrl: string - dbPostgresSchema?: string - redisHost?: string // either set redis host, or both sentinel name and hosts - redisSentinelName?: string - redisSentinelHosts?: string[] - redisPassword?: string - repoProvider: string - labelProvider?: string - bsyncUrl?: string - bsyncApiKey?: string - bsyncHttpVersion?: '1.1' | '2' - bsyncIgnoreBadTls?: boolean - ingesterPartitionCount: number - ingesterNamespace?: string - ingesterSubLockId?: number - ingesterMaxItems?: number - ingesterCheckItemsEveryN?: number - ingesterInitialCursor?: number -} - -export class IngesterConfig { - constructor(private cfg: IngesterConfigValues) {} - - static readEnv(overrides?: Partial) { - const version = process.env.BSKY_VERSION || '0.0.0' - const dbPostgresUrl = - overrides?.dbPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL - const dbPostgresSchema = - overrides?.dbPostgresSchema || process.env.DB_POSTGRES_SCHEMA - const redisHost = - overrides?.redisHost || process.env.REDIS_HOST || undefined - const redisSentinelName = - overrides?.redisSentinelName || - process.env.REDIS_SENTINEL_NAME || - undefined - const redisSentinelHosts = - overrides?.redisSentinelHosts || - (process.env.REDIS_SENTINEL_HOSTS - ? process.env.REDIS_SENTINEL_HOSTS.split(',') - : []) - const redisPassword = - overrides?.redisPassword || process.env.REDIS_PASSWORD || undefined - const repoProvider = overrides?.repoProvider || process.env.REPO_PROVIDER // E.g. ws://abc.com:4000 - const labelProvider = overrides?.labelProvider || process.env.LABEL_PROVIDER - const bsyncUrl = - overrides?.bsyncUrl || process.env.BSKY_BSYNC_URL || undefined - const bsyncApiKey = - overrides?.bsyncApiKey || process.env.BSKY_BSYNC_API_KEY || undefined - const bsyncHttpVersion = - overrides?.bsyncHttpVersion || process.env.BSKY_BSYNC_HTTP_VERSION || '2' - const bsyncIgnoreBadTls = - overrides?.bsyncIgnoreBadTls || - process.env.BSKY_BSYNC_IGNORE_BAD_TLS === 'true' - assert(bsyncHttpVersion === '1.1' || bsyncHttpVersion === '2') - const ingesterPartitionCount = - overrides?.ingesterPartitionCount || - maybeParseInt(process.env.INGESTER_PARTITION_COUNT) - const ingesterSubLockId = - overrides?.ingesterSubLockId || - maybeParseInt(process.env.INGESTER_SUB_LOCK_ID) - const ingesterMaxItems = - overrides?.ingesterMaxItems || - maybeParseInt(process.env.INGESTER_MAX_ITEMS) - const ingesterCheckItemsEveryN = - overrides?.ingesterCheckItemsEveryN || - maybeParseInt(process.env.INGESTER_CHECK_ITEMS_EVERY_N) - const ingesterInitialCursor = - overrides?.ingesterInitialCursor || - maybeParseInt(process.env.INGESTER_INITIAL_CURSOR) - const ingesterNamespace = overrides?.ingesterNamespace - assert(dbPostgresUrl) - assert(redisHost || (redisSentinelName && redisSentinelHosts?.length)) - assert(repoProvider) - assert(ingesterPartitionCount) - return new IngesterConfig({ - version, - dbPostgresUrl, - dbPostgresSchema, - redisHost, - redisSentinelName, - redisSentinelHosts, - redisPassword, - repoProvider, - labelProvider, - bsyncUrl, - bsyncApiKey, - bsyncHttpVersion, - bsyncIgnoreBadTls, - ingesterPartitionCount, - ingesterSubLockId, - ingesterNamespace, - ingesterMaxItems, - ingesterCheckItemsEveryN, - ingesterInitialCursor, - }) - } - - get version() { - return this.cfg.version - } - - get dbPostgresUrl() { - return this.cfg.dbPostgresUrl - } - - get dbPostgresSchema() { - return this.cfg.dbPostgresSchema - } - - get redisHost() { - return this.cfg.redisHost - } - - get redisSentinelName() { - return this.cfg.redisSentinelName - } - - get redisSentinelHosts() { - return this.cfg.redisSentinelHosts - } - - get redisPassword() { - return this.cfg.redisPassword - } - - get repoProvider() { - return this.cfg.repoProvider - } - - get labelProvider() { - return this.cfg.labelProvider - } - - get bsyncUrl() { - return this.cfg.bsyncUrl - } - - get bsyncApiKey() { - return this.cfg.bsyncApiKey - } - - get bsyncHttpVersion() { - return this.cfg.bsyncHttpVersion - } - - get bsyncIgnoreBadTls() { - return this.cfg.bsyncIgnoreBadTls - } - - get ingesterPartitionCount() { - return this.cfg.ingesterPartitionCount - } - - get ingesterMaxItems() { - return this.cfg.ingesterMaxItems - } - - get ingesterCheckItemsEveryN() { - return this.cfg.ingesterCheckItemsEveryN - } - - get ingesterInitialCursor() { - return this.cfg.ingesterInitialCursor - } - - get ingesterNamespace() { - return this.cfg.ingesterNamespace - } - - get ingesterSubLockId() { - return this.cfg.ingesterSubLockId - } -} - -function maybeParseInt(str) { - const parsed = parseInt(str) - return isNaN(parsed) ? undefined : parsed -} diff --git a/packages/bsky/src/ingester/context.ts b/packages/bsky/src/ingester/context.ts deleted file mode 100644 index debf9843ea6..00000000000 --- a/packages/bsky/src/ingester/context.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { PrimaryDatabase } from '../db' -import { Redis } from '../redis' -import { IngesterConfig } from './config' -import { LabelSubscription } from './label-subscription' -import { MuteSubscription } from './mute-subscription' - -export class IngesterContext { - constructor( - private opts: { - db: PrimaryDatabase - redis: Redis - cfg: IngesterConfig - labelSubscription?: LabelSubscription - muteSubscription?: MuteSubscription - }, - ) {} - - get db(): PrimaryDatabase { - return this.opts.db - } - - get redis(): Redis { - return this.opts.redis - } - - get cfg(): IngesterConfig { - return this.opts.cfg - } - - get labelSubscription(): LabelSubscription | undefined { - return this.opts.labelSubscription - } - - get muteSubscription(): MuteSubscription | undefined { - return this.opts.muteSubscription - } -} - -export default IngesterContext diff --git a/packages/bsky/src/ingester/index.ts b/packages/bsky/src/ingester/index.ts deleted file mode 100644 index 76225f13d38..00000000000 --- a/packages/bsky/src/ingester/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { PrimaryDatabase } from '../db' -import log from './logger' -import { dbLogger } from '../logger' -import { Redis } from '../redis' -import { IngesterConfig } from './config' -import { IngesterContext } from './context' -import { IngesterSubscription } from './subscription' -import { authWithApiKey, createBsyncClient } from '../bsync' -import { LabelSubscription } from './label-subscription' -import { MuteSubscription } from './mute-subscription' - -export { IngesterConfig } from './config' -export type { IngesterConfigValues } from './config' - -export class BskyIngester { - public ctx: IngesterContext - public sub: IngesterSubscription - private dbStatsInterval: NodeJS.Timer - private subStatsInterval: NodeJS.Timer - - constructor(opts: { ctx: IngesterContext; sub: IngesterSubscription }) { - this.ctx = opts.ctx - this.sub = opts.sub - } - - static create(opts: { - db: PrimaryDatabase - redis: Redis - cfg: IngesterConfig - }): BskyIngester { - const { db, redis, cfg } = opts - const bsyncClient = cfg.bsyncUrl - ? createBsyncClient({ - baseUrl: cfg.bsyncUrl, - httpVersion: cfg.bsyncHttpVersion ?? '2', - nodeOptions: { rejectUnauthorized: !cfg.bsyncIgnoreBadTls }, - interceptors: cfg.bsyncApiKey - ? [authWithApiKey(cfg.bsyncApiKey)] - : [], - }) - : undefined - const labelSubscription = cfg.labelProvider - ? new LabelSubscription(db, cfg.labelProvider) - : undefined - const muteSubscription = bsyncClient - ? new MuteSubscription(db, redis, bsyncClient) - : undefined - const ctx = new IngesterContext({ - db, - redis, - cfg, - labelSubscription, - muteSubscription, - }) - const sub = new IngesterSubscription(ctx, { - service: cfg.repoProvider, - subLockId: cfg.ingesterSubLockId, - partitionCount: cfg.ingesterPartitionCount, - maxItems: cfg.ingesterMaxItems, - checkItemsEveryN: cfg.ingesterCheckItemsEveryN, - initialCursor: cfg.ingesterInitialCursor, - }) - return new BskyIngester({ ctx, sub }) - } - - async start() { - const { db } = this.ctx - const pool = db.pool - this.dbStatsInterval = setInterval(() => { - dbLogger.info( - { - idleCount: pool.idleCount, - totalCount: pool.totalCount, - waitingCount: pool.waitingCount, - }, - 'db pool stats', - ) - }, 10000) - this.subStatsInterval = setInterval(() => { - log.info( - { - seq: this.sub.lastSeq, - streamsLength: - this.sub.backpressure.lastTotal !== null - ? this.sub.backpressure.lastTotal - : undefined, - }, - 'ingester stats', - ) - }, 500) - await this.ctx.labelSubscription?.start() - await this.ctx.muteSubscription?.start() - this.sub.run() - return this - } - - async destroy(opts?: { skipDb: boolean }): Promise { - await this.ctx.muteSubscription?.destroy() - await this.ctx.labelSubscription?.destroy() - await this.sub.destroy() - clearInterval(this.subStatsInterval) - await this.ctx.redis.destroy() - if (!opts?.skipDb) await this.ctx.db.close() - clearInterval(this.dbStatsInterval) - } -} - -export default BskyIngester diff --git a/packages/bsky/src/ingester/label-subscription.ts b/packages/bsky/src/ingester/label-subscription.ts deleted file mode 100644 index d486473cf98..00000000000 --- a/packages/bsky/src/ingester/label-subscription.ts +++ /dev/null @@ -1,76 +0,0 @@ -import AtpAgent from '@atproto/api' -import { PrimaryDatabase } from '../db' -import { sql } from 'kysely' -import { dbLogger } from '../logger' -import { SECOND } from '@atproto/common' - -export class LabelSubscription { - destroyed = false - promise: Promise = Promise.resolve() - timer: NodeJS.Timer | undefined - lastLabel: number | undefined - labelAgent: AtpAgent - - constructor(public db: PrimaryDatabase, public labelProvider: string) { - this.labelAgent = new AtpAgent({ service: labelProvider }) - } - - async start() { - const res = await this.db.db - .selectFrom('label') - .select('cts') - .orderBy('cts', 'desc') - .limit(1) - .executeTakeFirst() - this.lastLabel = res ? new Date(res.cts).getTime() : undefined - this.poll() - } - - poll() { - if (this.destroyed) return - this.promise = this.fetchLabels() - .catch((err) => - dbLogger.error({ err }, 'failed to fetch and store labels'), - ) - .finally(() => { - this.timer = setTimeout(() => this.poll(), SECOND) - }) - } - - async fetchLabels() { - const res = await this.labelAgent.api.com.atproto.temp.fetchLabels({ - since: this.lastLabel, - }) - const last = res.data.labels.at(-1) - if (!last) { - return - } - const dbVals = res.data.labels.map((l) => ({ - ...l, - cid: l.cid ?? '', - neg: l.neg ?? false, - })) - const { ref } = this.db.db.dynamic - const excluded = (col: string) => ref(`excluded.${col}`) - await this.db - .asPrimary() - .db.insertInto('label') - .values(dbVals) - .onConflict((oc) => - oc.columns(['src', 'uri', 'cid', 'val']).doUpdateSet({ - neg: sql`${excluded('neg')}`, - cts: sql`${excluded('cts')}`, - }), - ) - .execute() - this.lastLabel = new Date(last.cts).getTime() - } - - async destroy() { - this.destroyed = true - if (this.timer) { - clearTimeout(this.timer) - } - await this.promise - } -} diff --git a/packages/bsky/src/ingester/logger.ts b/packages/bsky/src/ingester/logger.ts deleted file mode 100644 index 49855166481..00000000000 --- a/packages/bsky/src/ingester/logger.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { subsystemLogger } from '@atproto/common' - -const logger: ReturnType = - subsystemLogger('bsky:ingester') - -export default logger diff --git a/packages/bsky/src/ingester/mute-subscription.ts b/packages/bsky/src/ingester/mute-subscription.ts deleted file mode 100644 index 9adb685c160..00000000000 --- a/packages/bsky/src/ingester/mute-subscription.ts +++ /dev/null @@ -1,213 +0,0 @@ -import assert from 'node:assert' -import { PrimaryDatabase } from '../db' -import { Redis } from '../redis' -import { BsyncClient, Code, isBsyncError } from '../bsync' -import { MuteOperation, MuteOperation_Type } from '../proto/bsync_pb' -import logger from './logger' -import { wait } from '@atproto/common' -import { - AtUri, - InvalidDidError, - ensureValidAtUri, - ensureValidDid, -} from '@atproto/syntax' -import { ids } from '../lexicon/lexicons' - -const CURSOR_KEY = 'ingester:mute:cursor' - -export class MuteSubscription { - ac = new AbortController() - running: Promise | undefined - cursor: string | null = null - - constructor( - public db: PrimaryDatabase, - public redis: Redis, - public bsyncClient: BsyncClient, - ) {} - - async start() { - if (this.running) return - this.ac = new AbortController() - this.running = this.run() - .catch((err) => { - // allow this to cause an unhandled rejection, let deployment handle the crash. - logger.error({ err }, 'mute subscription crashed') - throw err - }) - .finally(() => (this.running = undefined)) - } - - private async run() { - this.cursor = await this.getCursor() - while (!this.ac.signal.aborted) { - try { - // get page of mute ops, long-polling - const page = await this.bsyncClient.scanMuteOperations( - { - limit: 100, - cursor: this.cursor ?? undefined, - }, - { signal: this.ac.signal }, - ) - if (!page.cursor) { - throw new BadResponseError('cursor is missing') - } - // process - const now = new Date() - for (const op of page.operations) { - if (this.ac.signal.aborted) return - if (op.type === MuteOperation_Type.ADD) { - await this.handleAddOp(op, now) - } else if (op.type === MuteOperation_Type.REMOVE) { - await this.handleRemoveOp(op) - } else if (op.type === MuteOperation_Type.CLEAR) { - await this.handleClearOp(op) - } else { - logger.warn( - { id: op.id, type: op.type }, - 'unknown mute subscription op type', - ) - } - } - // update cursor - await this.setCursor(page.cursor) - this.cursor = page.cursor - } catch (err) { - if (isBsyncError(err, Code.Canceled)) { - return // canceled, probably from destroy() - } - if (err instanceof BadResponseError) { - logger.warn({ err }, 'bad response from bsync') - } else { - logger.error({ err }, 'unexpected error processing mute subscription') - } - await wait(1000) // wait a second before trying again - } - } - } - - async handleAddOp(op: MuteOperation, createdAt: Date) { - assert(op.type === MuteOperation_Type.ADD) - if (!isValidDid(op.actorDid)) { - logger.warn({ id: op.id, type: op.type }, 'bad actor in mute op') - return - } - if (isValidDid(op.subject)) { - await this.db.db - .insertInto('mute') - .values({ - subjectDid: op.subject, - mutedByDid: op.actorDid, - createdAt: createdAt.toISOString(), - }) - .onConflict((oc) => oc.doNothing()) - .execute() - } else { - const listUri = isValidAtUri(op.subject) - ? new AtUri(op.subject) - : undefined - if (listUri?.collection !== ids.AppBskyGraphList) { - logger.warn({ id: op.id, type: op.type }, 'bad subject in mute op') - return - } - await this.db.db - .insertInto('list_mute') - .values({ - listUri: op.subject, - mutedByDid: op.actorDid, - createdAt: createdAt.toISOString(), - }) - .onConflict((oc) => oc.doNothing()) - .execute() - } - } - - async handleRemoveOp(op: MuteOperation) { - assert(op.type === MuteOperation_Type.REMOVE) - if (!isValidDid(op.actorDid)) { - logger.warn({ id: op.id, type: op.type }, 'bad actor in mute op') - return - } - if (isValidDid(op.subject)) { - await this.db.db - .deleteFrom('mute') - .where('subjectDid', '=', op.subject) - .where('mutedByDid', '=', op.actorDid) - .execute() - } else { - const listUri = isValidAtUri(op.subject) - ? new AtUri(op.subject) - : undefined - if (listUri?.collection !== ids.AppBskyGraphList) { - logger.warn({ id: op.id, type: op.type }, 'bad subject in mute op') - return - } - await this.db.db - .deleteFrom('list_mute') - .where('listUri', '=', op.subject) - .where('mutedByDid', '=', op.actorDid) - .execute() - } - } - - async handleClearOp(op: MuteOperation) { - assert(op.type === MuteOperation_Type.CLEAR) - if (!isValidDid(op.actorDid)) { - logger.warn({ id: op.id, type: op.type }, 'bad actor in mute op') - return - } - if (op.subject) { - logger.warn({ id: op.id, type: op.type }, 'bad subject in mute op') - return - } - await this.db.db - .deleteFrom('mute') - .where('mutedByDid', '=', op.actorDid) - .execute() - await this.db.db - .deleteFrom('list_mute') - .where('mutedByDid', '=', op.actorDid) - .execute() - } - - async getCursor(): Promise { - return await this.redis.get(CURSOR_KEY) - } - - async setCursor(cursor: string): Promise { - await this.redis.set(CURSOR_KEY, cursor) - } - - async destroy() { - this.ac.abort() - await this.running - } - - get destroyed() { - return this.ac.signal.aborted - } -} - -class BadResponseError extends Error {} - -const isValidDid = (did: string) => { - try { - ensureValidDid(did) - return true - } catch (err) { - if (err instanceof InvalidDidError) { - return false - } - throw err - } -} - -const isValidAtUri = (uri: string) => { - try { - ensureValidAtUri(uri) - return true - } catch { - return false - } -} diff --git a/packages/bsky/src/ingester/subscription.ts b/packages/bsky/src/ingester/subscription.ts deleted file mode 100644 index 6db05ba1786..00000000000 --- a/packages/bsky/src/ingester/subscription.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { - Deferrable, - cborEncode, - createDeferrable, - ui8ToBuffer, - wait, -} from '@atproto/common' -import { randomIntFromSeed } from '@atproto/crypto' -import { DisconnectError, Subscription } from '@atproto/xrpc-server' -import { OutputSchema as Message } from '../lexicon/types/com/atproto/sync/subscribeRepos' -import * as message from '../lexicon/types/com/atproto/sync/subscribeRepos' -import { ids, lexicons } from '../lexicon/lexicons' -import { Leader } from '../db/leader' -import log from './logger' -import { - LatestQueue, - ProcessableMessage, - loggableMessage, - jitter, - strToInt, -} from '../subscription/util' -import { IngesterContext } from './context' - -const METHOD = ids.ComAtprotoSyncSubscribeRepos -const CURSOR_KEY = 'ingester:cursor' -export const INGESTER_SUB_LOCK_ID = 1000 - -export class IngesterSubscription { - cursorQueue = new LatestQueue() - destroyed = false - lastSeq: number | undefined - backpressure = new Backpressure(this) - leader = new Leader(this.opts.subLockId || INGESTER_SUB_LOCK_ID, this.ctx.db) - processor = new Processor(this) - - constructor( - public ctx: IngesterContext, - public opts: { - service: string - partitionCount: number - maxItems?: number - checkItemsEveryN?: number - subLockId?: number - initialCursor?: number - }, - ) {} - - async run() { - while (!this.destroyed) { - try { - const { ran } = await this.leader.run(async ({ signal }) => { - const sub = this.getSubscription({ signal }) - for await (const msg of sub) { - const details = getMessageDetails(msg) - if ('info' in details) { - // These messages are not sequenced, we just log them and carry on - log.warn( - { provider: this.opts.service, message: loggableMessage(msg) }, - `ingester sub ${details.info ? 'info' : 'unknown'} message`, - ) - continue - } - this.processor.send(details) - await this.backpressure.ready() - } - }) - if (ran && !this.destroyed) { - throw new Error('Ingester sub completed, but should be persistent') - } - } catch (err) { - log.error({ err, provider: this.opts.service }, 'ingester sub error') - } - if (!this.destroyed) { - await wait(1000 + jitter(500)) // wait then try to become leader - } - } - } - - async destroy() { - this.destroyed = true - await this.processor.destroy() - await this.cursorQueue.destroy() - this.leader.destroy(new DisconnectError()) - } - - async resume() { - this.destroyed = false - this.processor = new Processor(this) - this.cursorQueue = new LatestQueue() - await this.run() - } - - async getCursor(): Promise { - const val = await this.ctx.redis.get(CURSOR_KEY) - const initialCursor = this.opts.initialCursor ?? 0 - return val !== null ? strToInt(val) : initialCursor - } - - async resetCursor(): Promise { - await this.ctx.redis.del(CURSOR_KEY) - } - - async setCursor(seq: number): Promise { - await this.ctx.redis.set(CURSOR_KEY, seq) - } - - private getSubscription(opts: { signal: AbortSignal }) { - return new Subscription({ - service: this.opts.service, - method: METHOD, - signal: opts.signal, - getParams: async () => { - const cursor = await this.getCursor() - return { cursor } - }, - onReconnectError: (err, reconnects, initial) => { - log.warn({ err, reconnects, initial }, 'ingester sub reconnect') - }, - validate: (value) => { - try { - return lexicons.assertValidXrpcMessage(METHOD, value) - } catch (err) { - log.warn( - { - err, - seq: ifNumber(value?.['seq']), - repo: ifString(value?.['repo']), - commit: ifString(value?.['commit']?.toString()), - time: ifString(value?.['time']), - provider: this.opts.service, - }, - 'ingester sub skipped invalid message', - ) - } - }, - }) - } -} - -function ifString(val: unknown): string | undefined { - return typeof val === 'string' ? val : undefined -} - -function ifNumber(val: unknown): number | undefined { - return typeof val === 'number' ? val : undefined -} - -function getMessageDetails(msg: Message): - | { info: message.Info | null } - | { - seq: number - repo: string - message: ProcessableMessage - } { - if (message.isCommit(msg)) { - return { seq: msg.seq, repo: msg.repo, message: msg } - } else if (message.isHandle(msg)) { - return { seq: msg.seq, repo: msg.did, message: msg } - } else if (message.isIdentity(msg)) { - return { seq: msg.seq, repo: msg.did, message: msg } - } else if (message.isMigrate(msg)) { - return { seq: msg.seq, repo: msg.did, message: msg } - } else if (message.isTombstone(msg)) { - return { seq: msg.seq, repo: msg.did, message: msg } - } else if (message.isInfo(msg)) { - return { info: msg } - } - return { info: null } -} - -async function getPartition(did: string, n: number) { - const partition = await randomIntFromSeed(did, n) - return `repo:${partition}` -} - -class Processor { - running: Deferrable | null = null - destroyed = false - unprocessed: MessageEnvelope[] = [] - - constructor(public sub: IngesterSubscription) {} - - async handleBatch(batch: MessageEnvelope[]) { - if (!batch.length) return - const items = await Promise.all( - batch.map(async ({ seq, repo, message }) => { - const key = await getPartition(repo, this.sub.opts.partitionCount) - const fields: [string, string | Buffer][] = [ - ['repo', repo], - ['event', ui8ToBuffer(cborEncode(message))], - ] - return { key, id: seq, fields } - }), - ) - const results = await this.sub.ctx.redis.addMultiToStream(items) - results.forEach(([err], i) => { - if (err) { - // skipping over messages that have already been added or fully processed - const item = batch.at(i) - log.warn( - { seq: item?.seq, repo: item?.repo }, - 'ingester skipping message', - ) - } - }) - const lastSeq = batch[batch.length - 1].seq - this.sub.lastSeq = lastSeq - this.sub.cursorQueue.add(() => this.sub.setCursor(lastSeq)) - } - - async process() { - if (this.running || this.destroyed || !this.unprocessed.length) return - const next = this.unprocessed.splice(100) // pipeline no more than 100 - const processing = this.unprocessed - this.unprocessed = next - this.running = createDeferrable() - try { - await this.handleBatch(processing) - } catch (err) { - log.error( - { err, size: processing.length }, - 'ingester processing failed, rolling over to next batch', - ) - this.unprocessed.unshift(...processing) - } finally { - this.running.resolve() - this.running = null - this.process() - } - } - - send(envelope: MessageEnvelope) { - this.unprocessed.push(envelope) - this.process() - } - - async destroy() { - this.destroyed = true - this.unprocessed = [] - await this.running?.complete - } -} - -type MessageEnvelope = { - seq: number - repo: string - message: ProcessableMessage -} - -class Backpressure { - count = 0 - lastTotal: number | null = null - partitionCount = this.sub.opts.partitionCount - limit = this.sub.opts.maxItems ?? Infinity - checkEvery = this.sub.opts.checkItemsEveryN ?? 500 - - constructor(public sub: IngesterSubscription) {} - - async ready() { - this.count++ - const shouldCheck = - this.limit !== Infinity && - (this.count === 1 || this.count % this.checkEvery === 0) - if (!shouldCheck) return - let ready = false - const start = Date.now() - while (!ready) { - ready = await this.check() - if (!ready) { - log.warn( - { - limit: this.limit, - total: this.lastTotal, - duration: Date.now() - start, - }, - 'ingester backpressure', - ) - await wait(250) - } - } - } - - async check() { - const lens = await this.sub.ctx.redis.streamLengths( - [...Array(this.partitionCount)].map((_, i) => `repo:${i}`), - ) - this.lastTotal = lens.reduce((sum, len) => sum + len, 0) - return this.lastTotal < this.limit - } -} diff --git a/packages/bsky/src/notifications.ts b/packages/bsky/src/notifications.ts deleted file mode 100644 index d29913ec7d4..00000000000 --- a/packages/bsky/src/notifications.ts +++ /dev/null @@ -1,398 +0,0 @@ -import axios from 'axios' -import { Insertable, sql } from 'kysely' -import TTLCache from '@isaacs/ttlcache' -import { Struct, Timestamp } from '@bufbuild/protobuf' -import murmur from 'murmurhash' -import { AtUri } from '@atproto/api' -import { MINUTE, chunkArray } from '@atproto/common' -import Database from './db/primary' -import { Notification } from './db/tables/notification' -import { NotificationPushToken as PushToken } from './db/tables/notification-push-token' -import logger from './indexer/logger' -import { notSoftDeletedClause, valuesList } from './db/util' -import { ids } from './lexicon/lexicons' -import { retryConnect, retryHttp } from './util/retry' -import { Notification as CourierNotification } from './proto/courier_pb' -import { CourierClient } from './courier' - -export type Platform = 'ios' | 'android' | 'web' - -type GorushNotification = { - tokens: string[] - platform: 1 | 2 // 1 = ios, 2 = android - title: string - message: string - topic: string - data?: { - [key: string]: string - } - collapse_id?: string - collapse_key?: string -} - -type NotifRow = Insertable - -type NotifView = { - key: string - rateLimit: boolean - title: string - body: string - notif: NotifRow -} - -export abstract class NotificationServer { - constructor(public db: Database) {} - - abstract prepareNotifications(notifs: NotifRow[]): Promise - - abstract processNotifications(prepared: N[]): Promise - - async getNotificationViews(notifs: NotifRow[]): Promise { - const { ref } = this.db.db.dynamic - const authorDids = notifs.map((n) => n.author) - const subjectUris = notifs.flatMap((n) => n.reasonSubject ?? []) - const recordUris = notifs.map((n) => n.recordUri) - const allUris = [...subjectUris, ...recordUris] - - // gather potential display data for notifications in batch - const [authors, posts, blocksAndMutes] = await Promise.all([ - this.db.db - .selectFrom('actor') - .leftJoin('profile', 'profile.creator', 'actor.did') - .leftJoin('record', 'record.uri', 'profile.uri') - .where(notSoftDeletedClause(ref('actor'))) - .where(notSoftDeletedClause(ref('record'))) - .where('profile.creator', 'in', authorDids.length ? authorDids : ['']) - .select(['actor.did as did', 'handle', 'displayName']) - .execute(), - this.db.db - .selectFrom('post') - .innerJoin('actor', 'actor.did', 'post.creator') - .innerJoin('record', 'record.uri', 'post.uri') - .where(notSoftDeletedClause(ref('actor'))) - .where(notSoftDeletedClause(ref('record'))) - .where('post.uri', 'in', allUris.length ? allUris : ['']) - .select(['post.uri as uri', 'text']) - .execute(), - this.findBlocksAndMutes(notifs), - ]) - - const authorsByDid = authors.reduce((acc, author) => { - acc[author.did] = author - return acc - }, {} as Record) - const postsByUri = posts.reduce((acc, post) => { - acc[post.uri] = post - return acc - }, {} as Record) - - const results: NotifView[] = [] - - for (const notif of notifs) { - const { - author: authorDid, - reason, - reasonSubject: subjectUri, // if like/reply/quote/mention, the post which was liked/replied to/mention is in/or quoted. if custom feed liked, the feed which was liked - recordUri, - } = notif - - const author = - authorsByDid[authorDid]?.displayName || authorsByDid[authorDid]?.handle - const postRecord = postsByUri[recordUri] - const postSubject = subjectUri ? postsByUri[subjectUri] : null - - // if blocked or muted, don't send notification - const shouldFilter = blocksAndMutes.some( - (pair) => pair.author === notif.author && pair.receiver === notif.did, - ) - if (shouldFilter || !author) { - // if no display name, dont send notification - continue - } - // const author = displayName.displayName - - // 2. Get post data content - // if follow, get the URI of the author's profile - // if reply, or mention, get URI of the postRecord - // if like, or custom feed like, or repost get the URI of the reasonSubject - const key = reason - let title = '' - let body = '' - let rateLimit = true - - // check follow first and mention first because they don't have subjectUri and return - // reply has subjectUri but the recordUri is the replied post - if (reason === 'follow') { - title = 'New follower!' - body = `${author} has followed you` - results.push({ key, title, body, notif, rateLimit }) - continue - } else if (reason === 'mention' || reason === 'reply') { - // use recordUri for mention and reply - title = - reason === 'mention' - ? `${author} mentioned you` - : `${author} replied to your post` - body = postRecord?.text || '' - rateLimit = false // always deliver - results.push({ key, title, body, notif, rateLimit }) - continue - } - - // if no subjectUri, don't send notification - // at this point, subjectUri should exist for all the other reasons - if (!postSubject) { - continue - } - - if (reason === 'like') { - title = `${author} liked your post` - body = postSubject?.text || '' - // custom feed like - const uri = subjectUri ? new AtUri(subjectUri) : null - if (uri?.collection === ids.AppBskyFeedGenerator) { - title = `${author} liked your custom feed` - body = uri?.rkey ?? '' - } - } else if (reason === 'quote') { - title = `${author} quoted your post` - body = postSubject?.text || '' - rateLimit = true // always deliver - } else if (reason === 'repost') { - title = `${author} reposted your post` - body = postSubject?.text || '' - } - - if (title === '' && body === '') { - logger.warn( - { notif }, - 'No notification display attributes found for this notification. Either profile or post data for this notification is missing.', - ) - continue - } - - results.push({ key, title, body, notif, rateLimit }) - } - - return results - } - - private async findBlocksAndMutes(notifs: NotifRow[]) { - const pairs = notifs.map((n) => ({ author: n.author, receiver: n.did })) - const { ref } = this.db.db.dynamic - const blockQb = this.db.db - .selectFrom('actor_block') - .where((outer) => - outer - .where((qb) => - qb - .whereRef('actor_block.creator', '=', ref('author')) - .whereRef('actor_block.subjectDid', '=', ref('receiver')), - ) - .orWhere((qb) => - qb - .whereRef('actor_block.creator', '=', ref('receiver')) - .whereRef('actor_block.subjectDid', '=', ref('author')), - ), - ) - .select(['creator', 'subjectDid']) - const muteQb = this.db.db - .selectFrom('mute') - .whereRef('mute.subjectDid', '=', ref('author')) - .whereRef('mute.mutedByDid', '=', ref('receiver')) - .selectAll() - const muteListQb = this.db.db - .selectFrom('list_item') - .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') - .whereRef('list_mute.mutedByDid', '=', ref('receiver')) - .whereRef('list_item.subjectDid', '=', ref('author')) - .select('list_item.subjectDid') - - const values = valuesList(pairs.map((p) => sql`${p.author}, ${p.receiver}`)) - const filterPairs = await this.db.db - .selectFrom(values.as(sql`pair (author, receiver)`)) - .whereExists(muteQb) - .orWhereExists(muteListQb) - .orWhereExists(blockQb) - .selectAll() - .execute() - return filterPairs as { author: string; receiver: string }[] - } -} - -export class GorushNotificationServer extends NotificationServer { - private rateLimiter = new RateLimiter(1, 30 * MINUTE) - - constructor(public db: Database, public pushEndpoint: string) { - super(db) - } - - async prepareNotifications( - notifs: NotifRow[], - ): Promise { - const now = Date.now() - const notifsToSend: GorushNotification[] = [] - const tokensByDid = await this.getTokensByDid( - unique(notifs.map((n) => n.did)), - ) - // views for all notifications that have tokens - const notificationViews = await this.getNotificationViews( - notifs.filter((n) => tokensByDid[n.did]), - ) - - for (const notifView of notificationViews) { - if (!isRecent(notifView.notif.sortAt, 10 * MINUTE)) { - continue // if the notif is from > 10 minutes ago, don't send push notif - } - const { did: userDid } = notifView.notif - const userTokens = tokensByDid[userDid] ?? [] - for (const t of userTokens) { - const { appId, platform, token } = t - if (notifView.rateLimit && !this.rateLimiter.check(token, now)) { - continue - } - if (platform === 'ios' || platform === 'android') { - notifsToSend.push({ - tokens: [token], - platform: platform === 'ios' ? 1 : 2, - title: notifView.title, - message: notifView.body, - topic: appId, - data: { - reason: notifView.notif.reason, - recordUri: notifView.notif.recordUri, - recordCid: notifView.notif.recordCid, - }, - collapse_id: notifView.key, - collapse_key: notifView.key, - }) - } else { - // @TODO: Handle web notifs - logger.warn({ did: userDid }, 'cannot send web notification to user') - } - } - } - return notifsToSend - } - - async getTokensByDid(dids: string[]) { - if (!dids.length) return {} - const tokens = await this.db.db - .selectFrom('notification_push_token') - .where('did', 'in', dids) - .selectAll() - .execute() - return tokens.reduce((acc, token) => { - acc[token.did] ??= [] - acc[token.did].push(token) - return acc - }, {} as Record) - } - - async processNotifications(prepared: GorushNotification[]): Promise { - for (const batch of chunkArray(prepared, 20)) { - try { - await this.sendToGorush(batch) - } catch (err) { - logger.error({ err, batch }, 'notification push batch failed') - } - } - } - - private async sendToGorush(prepared: GorushNotification[]) { - // if no notifications, skip and return early - if (prepared.length === 0) { - return - } - const pushEndpoint = this.pushEndpoint - await retryHttp(() => - axios.post( - pushEndpoint, - { notifications: prepared }, - { - headers: { - 'content-type': 'application/json', - accept: 'application/json', - }, - }, - ), - ) - } -} - -export class CourierNotificationServer extends NotificationServer { - constructor(public db: Database, public courierClient: CourierClient) { - super(db) - } - - async prepareNotifications( - notifs: NotifRow[], - ): Promise { - const notificationViews = await this.getNotificationViews(notifs) - const notifsToSend = notificationViews.map((n) => { - return new CourierNotification({ - id: getCourierId(n), - recipientDid: n.notif.did, - title: n.title, - message: n.body, - collapseKey: n.key, - alwaysDeliver: !n.rateLimit, - timestamp: Timestamp.fromDate(new Date(n.notif.sortAt)), - additional: Struct.fromJson({ - uri: n.notif.recordUri, - reason: n.notif.reason, - subject: n.notif.reasonSubject || '', - }), - }) - }) - return notifsToSend - } - - async processNotifications(prepared: CourierNotification[]): Promise { - try { - await retryConnect(() => - this.courierClient.pushNotifications({ notifications: prepared }), - ) - } catch (err) { - logger.error({ err }, 'notification push to courier failed') - } - } -} - -const getCourierId = (notif: NotifView) => { - const key = [ - notif.notif.recordUri, - notif.notif.did, - notif.notif.reason, - notif.notif.reasonSubject || '', - ].join('::') - return murmur.v3(key).toString(16) -} - -const isRecent = (isoTime: string, timeDiff: number): boolean => { - const diff = Date.now() - new Date(isoTime).getTime() - return diff < timeDiff -} - -const unique = (items: string[]) => [...new Set(items)] - -class RateLimiter { - private rateLimitCache = new TTLCache({ - max: 50000, - ttl: this.windowMs, - noUpdateTTL: true, - }) - constructor(private limit: number, private windowMs: number) {} - check(token: string, now = Date.now()) { - const key = getRateLimitKey(token, now) - const last = this.rateLimitCache.get(key) ?? 0 - const current = last + 1 - this.rateLimitCache.set(key, current) - return current <= this.limit - } -} - -const getRateLimitKey = (token: string, now: number) => { - const iteration = Math.floor(now / (20 * MINUTE)) - return `${iteration}:${token}` -} diff --git a/packages/bsky/src/pipeline.ts b/packages/bsky/src/pipeline.ts index 7798519bfa2..50f1abfd566 100644 --- a/packages/bsky/src/pipeline.ts +++ b/packages/bsky/src/pipeline.ts @@ -1,22 +1,48 @@ -export function createPipeline< - Params, - SkeletonState, - HydrationState extends SkeletonState, - View, - Context, ->( - skeleton: (params: Params, ctx: Context) => Promise, - hydration: (state: SkeletonState, ctx: Context) => Promise, - rules: (state: HydrationState, ctx: Context) => HydrationState, - presentation: (state: HydrationState, ctx: Context) => View, +import { HydrationState } from './hydration/hydrator' + +export function createPipeline( + skeletonFn: (input: SkeletonFnInput) => Promise, + hydrationFn: ( + input: HydrationFnInput, + ) => Promise, + rulesFn: (input: RulesFnInput) => Skeleton, + presentationFn: ( + input: PresentationFnInput, + ) => View, ) { return async (params: Params, ctx: Context) => { - const skeletonState = await skeleton(params, ctx) - const hydrationState = await hydration(skeletonState, ctx) - return presentation(rules(hydrationState, ctx), ctx) + const skeleton = await skeletonFn({ ctx, params }) + const hydration = await hydrationFn({ ctx, params, skeleton }) + const appliedRules = rulesFn({ ctx, params, skeleton, hydration }) + return presentationFn({ ctx, params, skeleton: appliedRules, hydration }) } } -export function noRules(state: T) { - return state +export type SkeletonFnInput = { + ctx: Context + params: Params +} + +export type HydrationFnInput = { + ctx: Context + params: Params + skeleton: Skeleton +} + +export type RulesFnInput = { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +} + +export type PresentationFnInput = { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +} + +export function noRules(input: { skeleton: S }) { + return input.skeleton } diff --git a/packages/bsky/src/proto/bsky_connect.ts b/packages/bsky/src/proto/bsky_connect.ts new file mode 100644 index 00000000000..00e2e5b9204 --- /dev/null +++ b/packages/bsky/src/proto/bsky_connect.ts @@ -0,0 +1,920 @@ +// @generated by protoc-gen-connect-es v1.3.0 with parameter "target=ts,import_extension=.ts" +// @generated from file bsky.proto (package bsky, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { + ClearActorMutelistSubscriptionsRequest, + ClearActorMutelistSubscriptionsResponse, + ClearActorMutesRequest, + ClearActorMutesResponse, + CreateActorMutelistSubscriptionRequest, + CreateActorMutelistSubscriptionResponse, + CreateActorMuteRequest, + CreateActorMuteResponse, + DeleteActorMutelistSubscriptionRequest, + DeleteActorMutelistSubscriptionResponse, + DeleteActorMuteRequest, + DeleteActorMuteResponse, + GetActorFeedsRequest, + GetActorFeedsResponse, + GetActorFollowsActorsRequest, + GetActorFollowsActorsResponse, + GetActorLikesRequest, + GetActorLikesResponse, + GetActorListsRequest, + GetActorListsResponse, + GetActorMutesActorRequest, + GetActorMutesActorResponse, + GetActorMutesActorViaListRequest, + GetActorMutesActorViaListResponse, + GetActorRepostsRequest, + GetActorRepostsResponse, + GetActorsRequest, + GetActorsResponse, + GetActorTakedownRequest, + GetActorTakedownResponse, + GetAuthorFeedRequest, + GetAuthorFeedResponse, + GetBidirectionalBlockRequest, + GetBidirectionalBlockResponse, + GetBidirectionalBlockViaListRequest, + GetBidirectionalBlockViaListResponse, + GetBlobTakedownRequest, + GetBlobTakedownResponse, + GetBlockExistenceRequest, + GetBlockExistenceResponse, + GetBlocklistSubscriptionRequest, + GetBlocklistSubscriptionResponse, + GetBlocklistSubscriptionsRequest, + GetBlocklistSubscriptionsResponse, + GetBlockRecordsRequest, + GetBlockRecordsResponse, + GetBlocksRequest, + GetBlocksResponse, + GetCountsForUsersRequest, + GetCountsForUsersResponse, + GetDidsByHandlesRequest, + GetDidsByHandlesResponse, + GetFeedGeneratorRecordsRequest, + GetFeedGeneratorRecordsResponse, + GetFeedGeneratorStatusRequest, + GetFeedGeneratorStatusResponse, + GetFollowersRequest, + GetFollowersResponse, + GetFollowRecordsRequest, + GetFollowRecordsResponse, + GetFollowsRequest, + GetFollowsResponse, + GetFollowSuggestionsRequest, + GetFollowSuggestionsResponse, + GetIdentityByDidRequest, + GetIdentityByDidResponse, + GetIdentityByHandleRequest, + GetIdentityByHandleResponse, + GetInteractionCountsRequest, + GetInteractionCountsResponse, + GetLabelsRequest, + GetLabelsResponse, + GetLatestRevRequest, + GetLatestRevResponse, + GetLikeRecordsRequest, + GetLikeRecordsResponse, + GetLikesByActorAndSubjectsRequest, + GetLikesByActorAndSubjectsResponse, + GetLikesBySubjectRequest, + GetLikesBySubjectResponse, + GetListBlockRecordsRequest, + GetListBlockRecordsResponse, + GetListCountRequest, + GetListCountResponse, + GetListFeedRequest, + GetListFeedResponse, + GetListItemRecordsRequest, + GetListItemRecordsResponse, + GetListMembershipRequest, + GetListMembershipResponse, + GetListMembersRequest, + GetListMembersResponse, + GetListRecordsRequest, + GetListRecordsResponse, + GetMutelistSubscriptionRequest, + GetMutelistSubscriptionResponse, + GetMutelistSubscriptionsRequest, + GetMutelistSubscriptionsResponse, + GetMutesRequest, + GetMutesResponse, + GetNotificationSeenRequest, + GetNotificationSeenResponse, + GetNotificationsRequest, + GetNotificationsResponse, + GetPostRecordsRequest, + GetPostRecordsResponse, + GetPostReplyCountsRequest, + GetPostReplyCountsResponse, + GetProfileRecordsRequest, + GetProfileRecordsResponse, + GetRecordTakedownRequest, + GetRecordTakedownResponse, + GetRelationshipsRequest, + GetRelationshipsResponse, + GetRepostRecordsRequest, + GetRepostRecordsResponse, + GetRepostsByActorAndSubjectsRequest, + GetRepostsByActorAndSubjectsResponse, + GetRepostsBySubjectRequest, + GetRepostsBySubjectResponse, + GetSuggestedEntitiesRequest, + GetSuggestedEntitiesResponse, + GetSuggestedFeedsRequest, + GetSuggestedFeedsResponse, + GetThreadGateRecordsRequest, + GetThreadGateRecordsResponse, + GetThreadRequest, + GetThreadResponse, + GetTimelineRequest, + GetTimelineResponse, + GetUnreadNotificationCountRequest, + GetUnreadNotificationCountResponse, + PingRequest, + PingResponse, + SearchActorsRequest, + SearchActorsResponse, + SearchFeedGeneratorsRequest, + SearchFeedGeneratorsResponse, + SearchPostsRequest, + SearchPostsResponse, + TakedownActorRequest, + TakedownActorResponse, + TakedownBlobRequest, + TakedownBlobResponse, + TakedownRecordRequest, + TakedownRecordResponse, + UntakedownActorRequest, + UntakedownActorResponse, + UntakedownBlobRequest, + UntakedownBlobResponse, + UntakedownRecordRequest, + UntakedownRecordResponse, + UpdateNotificationSeenRequest, + UpdateNotificationSeenResponse, +} from './bsky_pb.ts' +import { MethodKind } from '@bufbuild/protobuf' + +/** + * + * Read Path + * + * + * @generated from service bsky.Service + */ +export const Service = { + typeName: 'bsky.Service', + methods: { + /** + * Records + * + * @generated from rpc bsky.Service.GetBlockRecords + */ + getBlockRecords: { + name: 'GetBlockRecords', + I: GetBlockRecordsRequest, + O: GetBlockRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetFeedGeneratorRecords + */ + getFeedGeneratorRecords: { + name: 'GetFeedGeneratorRecords', + I: GetFeedGeneratorRecordsRequest, + O: GetFeedGeneratorRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetFollowRecords + */ + getFollowRecords: { + name: 'GetFollowRecords', + I: GetFollowRecordsRequest, + O: GetFollowRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetLikeRecords + */ + getLikeRecords: { + name: 'GetLikeRecords', + I: GetLikeRecordsRequest, + O: GetLikeRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetListBlockRecords + */ + getListBlockRecords: { + name: 'GetListBlockRecords', + I: GetListBlockRecordsRequest, + O: GetListBlockRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetListItemRecords + */ + getListItemRecords: { + name: 'GetListItemRecords', + I: GetListItemRecordsRequest, + O: GetListItemRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetListRecords + */ + getListRecords: { + name: 'GetListRecords', + I: GetListRecordsRequest, + O: GetListRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetPostRecords + */ + getPostRecords: { + name: 'GetPostRecords', + I: GetPostRecordsRequest, + O: GetPostRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetProfileRecords + */ + getProfileRecords: { + name: 'GetProfileRecords', + I: GetProfileRecordsRequest, + O: GetProfileRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetRepostRecords + */ + getRepostRecords: { + name: 'GetRepostRecords', + I: GetRepostRecordsRequest, + O: GetRepostRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetThreadGateRecords + */ + getThreadGateRecords: { + name: 'GetThreadGateRecords', + I: GetThreadGateRecordsRequest, + O: GetThreadGateRecordsResponse, + kind: MethodKind.Unary, + }, + /** + * Follows + * + * @generated from rpc bsky.Service.GetActorFollowsActors + */ + getActorFollowsActors: { + name: 'GetActorFollowsActors', + I: GetActorFollowsActorsRequest, + O: GetActorFollowsActorsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetFollowers + */ + getFollowers: { + name: 'GetFollowers', + I: GetFollowersRequest, + O: GetFollowersResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetFollows + */ + getFollows: { + name: 'GetFollows', + I: GetFollowsRequest, + O: GetFollowsResponse, + kind: MethodKind.Unary, + }, + /** + * Likes + * + * @generated from rpc bsky.Service.GetLikesBySubject + */ + getLikesBySubject: { + name: 'GetLikesBySubject', + I: GetLikesBySubjectRequest, + O: GetLikesBySubjectResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetLikesByActorAndSubjects + */ + getLikesByActorAndSubjects: { + name: 'GetLikesByActorAndSubjects', + I: GetLikesByActorAndSubjectsRequest, + O: GetLikesByActorAndSubjectsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetActorLikes + */ + getActorLikes: { + name: 'GetActorLikes', + I: GetActorLikesRequest, + O: GetActorLikesResponse, + kind: MethodKind.Unary, + }, + /** + * Reposts + * + * @generated from rpc bsky.Service.GetRepostsBySubject + */ + getRepostsBySubject: { + name: 'GetRepostsBySubject', + I: GetRepostsBySubjectRequest, + O: GetRepostsBySubjectResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetRepostsByActorAndSubjects + */ + getRepostsByActorAndSubjects: { + name: 'GetRepostsByActorAndSubjects', + I: GetRepostsByActorAndSubjectsRequest, + O: GetRepostsByActorAndSubjectsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetActorReposts + */ + getActorReposts: { + name: 'GetActorReposts', + I: GetActorRepostsRequest, + O: GetActorRepostsResponse, + kind: MethodKind.Unary, + }, + /** + * Interaction Counts + * + * @generated from rpc bsky.Service.GetInteractionCounts + */ + getInteractionCounts: { + name: 'GetInteractionCounts', + I: GetInteractionCountsRequest, + O: GetInteractionCountsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetCountsForUsers + */ + getCountsForUsers: { + name: 'GetCountsForUsers', + I: GetCountsForUsersRequest, + O: GetCountsForUsersResponse, + kind: MethodKind.Unary, + }, + /** + * Profile + * + * @generated from rpc bsky.Service.GetActors + */ + getActors: { + name: 'GetActors', + I: GetActorsRequest, + O: GetActorsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetDidsByHandles + */ + getDidsByHandles: { + name: 'GetDidsByHandles', + I: GetDidsByHandlesRequest, + O: GetDidsByHandlesResponse, + kind: MethodKind.Unary, + }, + /** + * Relationships + * + * @generated from rpc bsky.Service.GetRelationships + */ + getRelationships: { + name: 'GetRelationships', + I: GetRelationshipsRequest, + O: GetRelationshipsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetBlockExistence + */ + getBlockExistence: { + name: 'GetBlockExistence', + I: GetBlockExistenceRequest, + O: GetBlockExistenceResponse, + kind: MethodKind.Unary, + }, + /** + * Lists + * + * @generated from rpc bsky.Service.GetActorLists + */ + getActorLists: { + name: 'GetActorLists', + I: GetActorListsRequest, + O: GetActorListsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetListMembers + */ + getListMembers: { + name: 'GetListMembers', + I: GetListMembersRequest, + O: GetListMembersResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetListMembership + */ + getListMembership: { + name: 'GetListMembership', + I: GetListMembershipRequest, + O: GetListMembershipResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetListCount + */ + getListCount: { + name: 'GetListCount', + I: GetListCountRequest, + O: GetListCountResponse, + kind: MethodKind.Unary, + }, + /** + * Mutes + * + * @generated from rpc bsky.Service.GetActorMutesActor + */ + getActorMutesActor: { + name: 'GetActorMutesActor', + I: GetActorMutesActorRequest, + O: GetActorMutesActorResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetMutes + */ + getMutes: { + name: 'GetMutes', + I: GetMutesRequest, + O: GetMutesResponse, + kind: MethodKind.Unary, + }, + /** + * Mutelists + * + * @generated from rpc bsky.Service.GetActorMutesActorViaList + */ + getActorMutesActorViaList: { + name: 'GetActorMutesActorViaList', + I: GetActorMutesActorViaListRequest, + O: GetActorMutesActorViaListResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetMutelistSubscription + */ + getMutelistSubscription: { + name: 'GetMutelistSubscription', + I: GetMutelistSubscriptionRequest, + O: GetMutelistSubscriptionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetMutelistSubscriptions + */ + getMutelistSubscriptions: { + name: 'GetMutelistSubscriptions', + I: GetMutelistSubscriptionsRequest, + O: GetMutelistSubscriptionsResponse, + kind: MethodKind.Unary, + }, + /** + * Blocks + * + * @generated from rpc bsky.Service.GetBidirectionalBlock + */ + getBidirectionalBlock: { + name: 'GetBidirectionalBlock', + I: GetBidirectionalBlockRequest, + O: GetBidirectionalBlockResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetBlocks + */ + getBlocks: { + name: 'GetBlocks', + I: GetBlocksRequest, + O: GetBlocksResponse, + kind: MethodKind.Unary, + }, + /** + * Blocklists + * + * @generated from rpc bsky.Service.GetBidirectionalBlockViaList + */ + getBidirectionalBlockViaList: { + name: 'GetBidirectionalBlockViaList', + I: GetBidirectionalBlockViaListRequest, + O: GetBidirectionalBlockViaListResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetBlocklistSubscription + */ + getBlocklistSubscription: { + name: 'GetBlocklistSubscription', + I: GetBlocklistSubscriptionRequest, + O: GetBlocklistSubscriptionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetBlocklistSubscriptions + */ + getBlocklistSubscriptions: { + name: 'GetBlocklistSubscriptions', + I: GetBlocklistSubscriptionsRequest, + O: GetBlocklistSubscriptionsResponse, + kind: MethodKind.Unary, + }, + /** + * Notifications + * + * @generated from rpc bsky.Service.GetNotifications + */ + getNotifications: { + name: 'GetNotifications', + I: GetNotificationsRequest, + O: GetNotificationsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetNotificationSeen + */ + getNotificationSeen: { + name: 'GetNotificationSeen', + I: GetNotificationSeenRequest, + O: GetNotificationSeenResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetUnreadNotificationCount + */ + getUnreadNotificationCount: { + name: 'GetUnreadNotificationCount', + I: GetUnreadNotificationCountRequest, + O: GetUnreadNotificationCountResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.UpdateNotificationSeen + */ + updateNotificationSeen: { + name: 'UpdateNotificationSeen', + I: UpdateNotificationSeenRequest, + O: UpdateNotificationSeenResponse, + kind: MethodKind.Unary, + }, + /** + * FeedGenerators + * + * @generated from rpc bsky.Service.GetActorFeeds + */ + getActorFeeds: { + name: 'GetActorFeeds', + I: GetActorFeedsRequest, + O: GetActorFeedsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetSuggestedFeeds + */ + getSuggestedFeeds: { + name: 'GetSuggestedFeeds', + I: GetSuggestedFeedsRequest, + O: GetSuggestedFeedsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetFeedGeneratorStatus + */ + getFeedGeneratorStatus: { + name: 'GetFeedGeneratorStatus', + I: GetFeedGeneratorStatusRequest, + O: GetFeedGeneratorStatusResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.SearchFeedGenerators + */ + searchFeedGenerators: { + name: 'SearchFeedGenerators', + I: SearchFeedGeneratorsRequest, + O: SearchFeedGeneratorsResponse, + kind: MethodKind.Unary, + }, + /** + * Feeds + * + * @generated from rpc bsky.Service.GetAuthorFeed + */ + getAuthorFeed: { + name: 'GetAuthorFeed', + I: GetAuthorFeedRequest, + O: GetAuthorFeedResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetTimeline + */ + getTimeline: { + name: 'GetTimeline', + I: GetTimelineRequest, + O: GetTimelineResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetListFeed + */ + getListFeed: { + name: 'GetListFeed', + I: GetListFeedRequest, + O: GetListFeedResponse, + kind: MethodKind.Unary, + }, + /** + * Threads + * + * @generated from rpc bsky.Service.GetThread + */ + getThread: { + name: 'GetThread', + I: GetThreadRequest, + O: GetThreadResponse, + kind: MethodKind.Unary, + }, + /** + * Search + * + * @generated from rpc bsky.Service.SearchActors + */ + searchActors: { + name: 'SearchActors', + I: SearchActorsRequest, + O: SearchActorsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.SearchPosts + */ + searchPosts: { + name: 'SearchPosts', + I: SearchPostsRequest, + O: SearchPostsResponse, + kind: MethodKind.Unary, + }, + /** + * Suggestions + * + * @generated from rpc bsky.Service.GetFollowSuggestions + */ + getFollowSuggestions: { + name: 'GetFollowSuggestions', + I: GetFollowSuggestionsRequest, + O: GetFollowSuggestionsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetSuggestedEntities + */ + getSuggestedEntities: { + name: 'GetSuggestedEntities', + I: GetSuggestedEntitiesRequest, + O: GetSuggestedEntitiesResponse, + kind: MethodKind.Unary, + }, + /** + * Posts + * + * @generated from rpc bsky.Service.GetPostReplyCounts + */ + getPostReplyCounts: { + name: 'GetPostReplyCounts', + I: GetPostReplyCountsRequest, + O: GetPostReplyCountsResponse, + kind: MethodKind.Unary, + }, + /** + * Labels + * + * @generated from rpc bsky.Service.GetLabels + */ + getLabels: { + name: 'GetLabels', + I: GetLabelsRequest, + O: GetLabelsResponse, + kind: MethodKind.Unary, + }, + /** + * Sync + * + * @generated from rpc bsky.Service.GetLatestRev + */ + getLatestRev: { + name: 'GetLatestRev', + I: GetLatestRevRequest, + O: GetLatestRevResponse, + kind: MethodKind.Unary, + }, + /** + * Moderation + * + * @generated from rpc bsky.Service.GetBlobTakedown + */ + getBlobTakedown: { + name: 'GetBlobTakedown', + I: GetBlobTakedownRequest, + O: GetBlobTakedownResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetRecordTakedown + */ + getRecordTakedown: { + name: 'GetRecordTakedown', + I: GetRecordTakedownRequest, + O: GetRecordTakedownResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetActorTakedown + */ + getActorTakedown: { + name: 'GetActorTakedown', + I: GetActorTakedownRequest, + O: GetActorTakedownResponse, + kind: MethodKind.Unary, + }, + /** + * Identity + * + * @generated from rpc bsky.Service.GetIdentityByDid + */ + getIdentityByDid: { + name: 'GetIdentityByDid', + I: GetIdentityByDidRequest, + O: GetIdentityByDidResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetIdentityByHandle + */ + getIdentityByHandle: { + name: 'GetIdentityByHandle', + I: GetIdentityByHandleRequest, + O: GetIdentityByHandleResponse, + kind: MethodKind.Unary, + }, + /** + * Ping + * + * @generated from rpc bsky.Service.Ping + */ + ping: { + name: 'Ping', + I: PingRequest, + O: PingResponse, + kind: MethodKind.Unary, + }, + /** + * Moderation + * + * @generated from rpc bsky.Service.TakedownBlob + */ + takedownBlob: { + name: 'TakedownBlob', + I: TakedownBlobRequest, + O: TakedownBlobResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.TakedownRecord + */ + takedownRecord: { + name: 'TakedownRecord', + I: TakedownRecordRequest, + O: TakedownRecordResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.TakedownActor + */ + takedownActor: { + name: 'TakedownActor', + I: TakedownActorRequest, + O: TakedownActorResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.UntakedownBlob + */ + untakedownBlob: { + name: 'UntakedownBlob', + I: UntakedownBlobRequest, + O: UntakedownBlobResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.UntakedownRecord + */ + untakedownRecord: { + name: 'UntakedownRecord', + I: UntakedownRecordRequest, + O: UntakedownRecordResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.UntakedownActor + */ + untakedownActor: { + name: 'UntakedownActor', + I: UntakedownActorRequest, + O: UntakedownActorResponse, + kind: MethodKind.Unary, + }, + /** + * Ingestion + * + * @generated from rpc bsky.Service.CreateActorMute + */ + createActorMute: { + name: 'CreateActorMute', + I: CreateActorMuteRequest, + O: CreateActorMuteResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.DeleteActorMute + */ + deleteActorMute: { + name: 'DeleteActorMute', + I: DeleteActorMuteRequest, + O: DeleteActorMuteResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.ClearActorMutes + */ + clearActorMutes: { + name: 'ClearActorMutes', + I: ClearActorMutesRequest, + O: ClearActorMutesResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.CreateActorMutelistSubscription + */ + createActorMutelistSubscription: { + name: 'CreateActorMutelistSubscription', + I: CreateActorMutelistSubscriptionRequest, + O: CreateActorMutelistSubscriptionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.DeleteActorMutelistSubscription + */ + deleteActorMutelistSubscription: { + name: 'DeleteActorMutelistSubscription', + I: DeleteActorMutelistSubscriptionRequest, + O: DeleteActorMutelistSubscriptionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.ClearActorMutelistSubscriptions + */ + clearActorMutelistSubscriptions: { + name: 'ClearActorMutelistSubscriptions', + I: ClearActorMutelistSubscriptionsRequest, + O: ClearActorMutelistSubscriptionsResponse, + kind: MethodKind.Unary, + }, + }, +} as const diff --git a/packages/bsky/src/proto/bsky_pb.ts b/packages/bsky/src/proto/bsky_pb.ts new file mode 100644 index 00000000000..7c5ddcf1865 --- /dev/null +++ b/packages/bsky/src/proto/bsky_pb.ts @@ -0,0 +1,10619 @@ +// @generated by protoc-gen-es v1.6.0 with parameter "target=ts,import_extension=.ts" +// @generated from file bsky.proto (package bsky, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { + BinaryReadOptions, + FieldList, + JsonReadOptions, + JsonValue, + PartialMessage, + PlainMessage, +} from '@bufbuild/protobuf' +import { Message, proto3, protoInt64, Timestamp } from '@bufbuild/protobuf' + +/** + * @generated from enum bsky.FeedType + */ +export enum FeedType { + /** + * @generated from enum value: FEED_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: FEED_TYPE_POSTS_AND_AUTHOR_THREADS = 1; + */ + POSTS_AND_AUTHOR_THREADS = 1, + + /** + * @generated from enum value: FEED_TYPE_POSTS_NO_REPLIES = 2; + */ + POSTS_NO_REPLIES = 2, + + /** + * @generated from enum value: FEED_TYPE_POSTS_WITH_MEDIA = 3; + */ + POSTS_WITH_MEDIA = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(FeedType) +proto3.util.setEnumType(FeedType, 'bsky.FeedType', [ + { no: 0, name: 'FEED_TYPE_UNSPECIFIED' }, + { no: 1, name: 'FEED_TYPE_POSTS_AND_AUTHOR_THREADS' }, + { no: 2, name: 'FEED_TYPE_POSTS_NO_REPLIES' }, + { no: 3, name: 'FEED_TYPE_POSTS_WITH_MEDIA' }, +]) + +/** + * @generated from message bsky.Record + */ +export class Record extends Message { + /** + * @generated from field: bytes record = 1; + */ + record = new Uint8Array(0) + + /** + * @generated from field: string cid = 2; + */ + cid = '' + + /** + * @generated from field: google.protobuf.Timestamp indexed_at = 4; + */ + indexedAt?: Timestamp + + /** + * @generated from field: bool taken_down = 5; + */ + takenDown = false + + /** + * @generated from field: google.protobuf.Timestamp created_at = 6; + */ + createdAt?: Timestamp + + /** + * @generated from field: google.protobuf.Timestamp sorted_at = 7; + */ + sortedAt?: Timestamp + + /** + * @generated from field: string takedown_ref = 8; + */ + takedownRef = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.Record' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'record', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 2, name: 'cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 4, name: 'indexed_at', kind: 'message', T: Timestamp }, + { no: 5, name: 'taken_down', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { no: 6, name: 'created_at', kind: 'message', T: Timestamp }, + { no: 7, name: 'sorted_at', kind: 'message', T: Timestamp }, + { + no: 8, + name: 'takedown_ref', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): Record { + return new Record().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): Record { + return new Record().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): Record { + return new Record().fromJsonString(jsonString, options) + } + + static equals( + a: Record | PlainMessage | undefined, + b: Record | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(Record, a, b) + } +} + +/** + * @generated from message bsky.GetBlockRecordsRequest + */ +export class GetBlockRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlockRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlockRecordsRequest { + return new GetBlockRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlockRecordsRequest { + return new GetBlockRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlockRecordsRequest { + return new GetBlockRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetBlockRecordsRequest + | PlainMessage + | undefined, + b: + | GetBlockRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlockRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetBlockRecordsResponse + */ +export class GetBlockRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlockRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlockRecordsResponse { + return new GetBlockRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlockRecordsResponse { + return new GetBlockRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlockRecordsResponse { + return new GetBlockRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetBlockRecordsResponse + | PlainMessage + | undefined, + b: + | GetBlockRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlockRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetFeedGeneratorRecordsRequest + */ +export class GetFeedGeneratorRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFeedGeneratorRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFeedGeneratorRecordsRequest { + return new GetFeedGeneratorRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFeedGeneratorRecordsRequest { + return new GetFeedGeneratorRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFeedGeneratorRecordsRequest { + return new GetFeedGeneratorRecordsRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetFeedGeneratorRecordsRequest + | PlainMessage + | undefined, + b: + | GetFeedGeneratorRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetFeedGeneratorRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetFeedGeneratorRecordsResponse + */ +export class GetFeedGeneratorRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFeedGeneratorRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFeedGeneratorRecordsResponse { + return new GetFeedGeneratorRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFeedGeneratorRecordsResponse { + return new GetFeedGeneratorRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFeedGeneratorRecordsResponse { + return new GetFeedGeneratorRecordsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetFeedGeneratorRecordsResponse + | PlainMessage + | undefined, + b: + | GetFeedGeneratorRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetFeedGeneratorRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetFollowRecordsRequest + */ +export class GetFollowRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFollowRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFollowRecordsRequest { + return new GetFollowRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFollowRecordsRequest { + return new GetFollowRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFollowRecordsRequest { + return new GetFollowRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetFollowRecordsRequest + | PlainMessage + | undefined, + b: + | GetFollowRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetFollowRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetFollowRecordsResponse + */ +export class GetFollowRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFollowRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFollowRecordsResponse { + return new GetFollowRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFollowRecordsResponse { + return new GetFollowRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFollowRecordsResponse { + return new GetFollowRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetFollowRecordsResponse + | PlainMessage + | undefined, + b: + | GetFollowRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetFollowRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetLikeRecordsRequest + */ +export class GetLikeRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLikeRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLikeRecordsRequest { + return new GetLikeRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLikeRecordsRequest { + return new GetLikeRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLikeRecordsRequest { + return new GetLikeRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetLikeRecordsRequest | PlainMessage | undefined, + b: GetLikeRecordsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetLikeRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetLikeRecordsResponse + */ +export class GetLikeRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLikeRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLikeRecordsResponse { + return new GetLikeRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLikeRecordsResponse { + return new GetLikeRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLikeRecordsResponse { + return new GetLikeRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetLikeRecordsResponse + | PlainMessage + | undefined, + b: + | GetLikeRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetLikeRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetListBlockRecordsRequest + */ +export class GetListBlockRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListBlockRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListBlockRecordsRequest { + return new GetListBlockRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListBlockRecordsRequest { + return new GetListBlockRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListBlockRecordsRequest { + return new GetListBlockRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetListBlockRecordsRequest + | PlainMessage + | undefined, + b: + | GetListBlockRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetListBlockRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetListBlockRecordsResponse + */ +export class GetListBlockRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListBlockRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListBlockRecordsResponse { + return new GetListBlockRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListBlockRecordsResponse { + return new GetListBlockRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListBlockRecordsResponse { + return new GetListBlockRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetListBlockRecordsResponse + | PlainMessage + | undefined, + b: + | GetListBlockRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetListBlockRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetListItemRecordsRequest + */ +export class GetListItemRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListItemRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListItemRecordsRequest { + return new GetListItemRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListItemRecordsRequest { + return new GetListItemRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListItemRecordsRequest { + return new GetListItemRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetListItemRecordsRequest + | PlainMessage + | undefined, + b: + | GetListItemRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetListItemRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetListItemRecordsResponse + */ +export class GetListItemRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListItemRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListItemRecordsResponse { + return new GetListItemRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListItemRecordsResponse { + return new GetListItemRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListItemRecordsResponse { + return new GetListItemRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetListItemRecordsResponse + | PlainMessage + | undefined, + b: + | GetListItemRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetListItemRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetListRecordsRequest + */ +export class GetListRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListRecordsRequest { + return new GetListRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListRecordsRequest { + return new GetListRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListRecordsRequest { + return new GetListRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetListRecordsRequest | PlainMessage | undefined, + b: GetListRecordsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetListRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetListRecordsResponse + */ +export class GetListRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListRecordsResponse { + return new GetListRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListRecordsResponse { + return new GetListRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListRecordsResponse { + return new GetListRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetListRecordsResponse + | PlainMessage + | undefined, + b: + | GetListRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetListRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.PostRecordMeta + */ +export class PostRecordMeta extends Message { + /** + * @generated from field: bool violates_thread_gate = 1; + */ + violatesThreadGate = false + + /** + * @generated from field: bool has_media = 2; + */ + hasMedia = false + + /** + * @generated from field: bool is_reply = 3; + */ + isReply = false + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.PostRecordMeta' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'violates_thread_gate', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { no: 2, name: 'has_media', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { no: 3, name: 'is_reply', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): PostRecordMeta { + return new PostRecordMeta().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): PostRecordMeta { + return new PostRecordMeta().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): PostRecordMeta { + return new PostRecordMeta().fromJsonString(jsonString, options) + } + + static equals( + a: PostRecordMeta | PlainMessage | undefined, + b: PostRecordMeta | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(PostRecordMeta, a, b) + } +} + +/** + * @generated from message bsky.GetPostRecordsRequest + */ +export class GetPostRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetPostRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetPostRecordsRequest { + return new GetPostRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetPostRecordsRequest { + return new GetPostRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetPostRecordsRequest { + return new GetPostRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetPostRecordsRequest | PlainMessage | undefined, + b: GetPostRecordsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetPostRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetPostRecordsResponse + */ +export class GetPostRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + /** + * @generated from field: repeated bsky.PostRecordMeta meta = 2; + */ + meta: PostRecordMeta[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetPostRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + { no: 2, name: 'meta', kind: 'message', T: PostRecordMeta, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetPostRecordsResponse { + return new GetPostRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetPostRecordsResponse { + return new GetPostRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetPostRecordsResponse { + return new GetPostRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetPostRecordsResponse + | PlainMessage + | undefined, + b: + | GetPostRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetPostRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetProfileRecordsRequest + */ +export class GetProfileRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetProfileRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetProfileRecordsRequest { + return new GetProfileRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetProfileRecordsRequest { + return new GetProfileRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetProfileRecordsRequest { + return new GetProfileRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetProfileRecordsRequest + | PlainMessage + | undefined, + b: + | GetProfileRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetProfileRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetProfileRecordsResponse + */ +export class GetProfileRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetProfileRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetProfileRecordsResponse { + return new GetProfileRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetProfileRecordsResponse { + return new GetProfileRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetProfileRecordsResponse { + return new GetProfileRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetProfileRecordsResponse + | PlainMessage + | undefined, + b: + | GetProfileRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetProfileRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetRepostRecordsRequest + */ +export class GetRepostRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRepostRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRepostRecordsRequest { + return new GetRepostRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRepostRecordsRequest { + return new GetRepostRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRepostRecordsRequest { + return new GetRepostRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetRepostRecordsRequest + | PlainMessage + | undefined, + b: + | GetRepostRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRepostRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetRepostRecordsResponse + */ +export class GetRepostRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRepostRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRepostRecordsResponse { + return new GetRepostRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRepostRecordsResponse { + return new GetRepostRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRepostRecordsResponse { + return new GetRepostRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetRepostRecordsResponse + | PlainMessage + | undefined, + b: + | GetRepostRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRepostRecordsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetThreadGateRecordsRequest + */ +export class GetThreadGateRecordsRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetThreadGateRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetThreadGateRecordsRequest { + return new GetThreadGateRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetThreadGateRecordsRequest { + return new GetThreadGateRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetThreadGateRecordsRequest { + return new GetThreadGateRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetThreadGateRecordsRequest + | PlainMessage + | undefined, + b: + | GetThreadGateRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetThreadGateRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetThreadGateRecordsResponse + */ +export class GetThreadGateRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetThreadGateRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetThreadGateRecordsResponse { + return new GetThreadGateRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetThreadGateRecordsResponse { + return new GetThreadGateRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetThreadGateRecordsResponse { + return new GetThreadGateRecordsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetThreadGateRecordsResponse + | PlainMessage + | undefined, + b: + | GetThreadGateRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetThreadGateRecordsResponse, a, b) + } +} + +/** + * - Return follow uris where user A follows users B, C, D, … + * - E.g. for viewer state on `getProfiles` + * + * @generated from message bsky.GetActorFollowsActorsRequest + */ +export class GetActorFollowsActorsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: repeated string target_dids = 2; + */ + targetDids: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorFollowsActorsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'target_dids', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorFollowsActorsRequest { + return new GetActorFollowsActorsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorFollowsActorsRequest { + return new GetActorFollowsActorsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorFollowsActorsRequest { + return new GetActorFollowsActorsRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetActorFollowsActorsRequest + | PlainMessage + | undefined, + b: + | GetActorFollowsActorsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorFollowsActorsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetActorFollowsActorsResponse + */ +export class GetActorFollowsActorsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorFollowsActorsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorFollowsActorsResponse { + return new GetActorFollowsActorsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorFollowsActorsResponse { + return new GetActorFollowsActorsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorFollowsActorsResponse { + return new GetActorFollowsActorsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetActorFollowsActorsResponse + | PlainMessage + | undefined, + b: + | GetActorFollowsActorsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorFollowsActorsResponse, a, b) + } +} + +/** + * - Return follow uris of users who follows user A + * - For `getFollowers` list + * + * @generated from message bsky.GetFollowersRequest + */ +export class GetFollowersRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFollowersRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFollowersRequest { + return new GetFollowersRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFollowersRequest { + return new GetFollowersRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFollowersRequest { + return new GetFollowersRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetFollowersRequest | PlainMessage | undefined, + b: GetFollowersRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetFollowersRequest, a, b) + } +} + +/** + * @generated from message bsky.FollowInfo + */ +export class FollowInfo extends Message { + /** + * @generated from field: string uri = 1; + */ + uri = '' + + /** + * @generated from field: string actor_did = 2; + */ + actorDid = '' + + /** + * @generated from field: string subject_did = 3; + */ + subjectDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.FollowInfo' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 3, + name: 'subject_did', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): FollowInfo { + return new FollowInfo().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): FollowInfo { + return new FollowInfo().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): FollowInfo { + return new FollowInfo().fromJsonString(jsonString, options) + } + + static equals( + a: FollowInfo | PlainMessage | undefined, + b: FollowInfo | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(FollowInfo, a, b) + } +} + +/** + * @generated from message bsky.GetFollowersResponse + */ +export class GetFollowersResponse extends Message { + /** + * @generated from field: repeated bsky.FollowInfo followers = 1; + */ + followers: FollowInfo[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFollowersResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'followers', + kind: 'message', + T: FollowInfo, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFollowersResponse { + return new GetFollowersResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFollowersResponse { + return new GetFollowersResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFollowersResponse { + return new GetFollowersResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetFollowersResponse | PlainMessage | undefined, + b: GetFollowersResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetFollowersResponse, a, b) + } +} + +/** + * - Return follow uris of users A follows + * - For `getFollows` list + * + * @generated from message bsky.GetFollowsRequest + */ +export class GetFollowsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFollowsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFollowsRequest { + return new GetFollowsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFollowsRequest { + return new GetFollowsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFollowsRequest { + return new GetFollowsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetFollowsRequest | PlainMessage | undefined, + b: GetFollowsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetFollowsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetFollowsResponse + */ +export class GetFollowsResponse extends Message { + /** + * @generated from field: repeated bsky.FollowInfo follows = 1; + */ + follows: FollowInfo[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFollowsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'follows', kind: 'message', T: FollowInfo, repeated: true }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFollowsResponse { + return new GetFollowsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFollowsResponse { + return new GetFollowsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFollowsResponse { + return new GetFollowsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetFollowsResponse | PlainMessage | undefined, + b: GetFollowsResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetFollowsResponse, a, b) + } +} + +/** + * - return like uris where subject uri is subject A + * - `getLikes` list for a post + * + * @generated from message bsky.GetLikesBySubjectRequest + */ +export class GetLikesBySubjectRequest extends Message { + /** + * @generated from field: bsky.RecordRef subject = 1; + */ + subject?: RecordRef + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLikesBySubjectRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'subject', kind: 'message', T: RecordRef }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLikesBySubjectRequest { + return new GetLikesBySubjectRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLikesBySubjectRequest { + return new GetLikesBySubjectRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLikesBySubjectRequest { + return new GetLikesBySubjectRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetLikesBySubjectRequest + | PlainMessage + | undefined, + b: + | GetLikesBySubjectRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetLikesBySubjectRequest, a, b) + } +} + +/** + * @generated from message bsky.GetLikesBySubjectResponse + */ +export class GetLikesBySubjectResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLikesBySubjectResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLikesBySubjectResponse { + return new GetLikesBySubjectResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLikesBySubjectResponse { + return new GetLikesBySubjectResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLikesBySubjectResponse { + return new GetLikesBySubjectResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetLikesBySubjectResponse + | PlainMessage + | undefined, + b: + | GetLikesBySubjectResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetLikesBySubjectResponse, a, b) + } +} + +/** + * - return like uris for user A on subject B, C, D... + * - viewer state on posts + * + * @generated from message bsky.GetLikesByActorAndSubjectsRequest + */ +export class GetLikesByActorAndSubjectsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: repeated bsky.RecordRef refs = 2; + */ + refs: RecordRef[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLikesByActorAndSubjectsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'refs', kind: 'message', T: RecordRef, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLikesByActorAndSubjectsRequest { + return new GetLikesByActorAndSubjectsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLikesByActorAndSubjectsRequest { + return new GetLikesByActorAndSubjectsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLikesByActorAndSubjectsRequest { + return new GetLikesByActorAndSubjectsRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetLikesByActorAndSubjectsRequest + | PlainMessage + | undefined, + b: + | GetLikesByActorAndSubjectsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetLikesByActorAndSubjectsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetLikesByActorAndSubjectsResponse + */ +export class GetLikesByActorAndSubjectsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLikesByActorAndSubjectsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLikesByActorAndSubjectsResponse { + return new GetLikesByActorAndSubjectsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLikesByActorAndSubjectsResponse { + return new GetLikesByActorAndSubjectsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLikesByActorAndSubjectsResponse { + return new GetLikesByActorAndSubjectsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetLikesByActorAndSubjectsResponse + | PlainMessage + | undefined, + b: + | GetLikesByActorAndSubjectsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetLikesByActorAndSubjectsResponse, a, b) + } +} + +/** + * - return recent like uris for user A + * - `getActorLikes` list for a user + * + * @generated from message bsky.GetActorLikesRequest + */ +export class GetActorLikesRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorLikesRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorLikesRequest { + return new GetActorLikesRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorLikesRequest { + return new GetActorLikesRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorLikesRequest { + return new GetActorLikesRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetActorLikesRequest | PlainMessage | undefined, + b: GetActorLikesRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetActorLikesRequest, a, b) + } +} + +/** + * @generated from message bsky.LikeInfo + */ +export class LikeInfo extends Message { + /** + * @generated from field: string uri = 1; + */ + uri = '' + + /** + * @generated from field: string subject = 2; + */ + subject = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.LikeInfo' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'subject', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): LikeInfo { + return new LikeInfo().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): LikeInfo { + return new LikeInfo().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): LikeInfo { + return new LikeInfo().fromJsonString(jsonString, options) + } + + static equals( + a: LikeInfo | PlainMessage | undefined, + b: LikeInfo | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(LikeInfo, a, b) + } +} + +/** + * @generated from message bsky.GetActorLikesResponse + */ +export class GetActorLikesResponse extends Message { + /** + * @generated from field: repeated bsky.LikeInfo likes = 1; + */ + likes: LikeInfo[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorLikesResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'likes', kind: 'message', T: LikeInfo, repeated: true }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorLikesResponse { + return new GetActorLikesResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorLikesResponse { + return new GetActorLikesResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorLikesResponse { + return new GetActorLikesResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetActorLikesResponse | PlainMessage | undefined, + b: GetActorLikesResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetActorLikesResponse, a, b) + } +} + +/** + * + * Interactions + * + * + * @generated from message bsky.GetInteractionCountsRequest + */ +export class GetInteractionCountsRequest extends Message { + /** + * @generated from field: repeated bsky.RecordRef refs = 1; + */ + refs: RecordRef[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetInteractionCountsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'refs', kind: 'message', T: RecordRef, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetInteractionCountsRequest { + return new GetInteractionCountsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetInteractionCountsRequest { + return new GetInteractionCountsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetInteractionCountsRequest { + return new GetInteractionCountsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetInteractionCountsRequest + | PlainMessage + | undefined, + b: + | GetInteractionCountsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetInteractionCountsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetInteractionCountsResponse + */ +export class GetInteractionCountsResponse extends Message { + /** + * @generated from field: repeated int32 likes = 1; + */ + likes: number[] = [] + + /** + * @generated from field: repeated int32 reposts = 2; + */ + reposts: number[] = [] + + /** + * @generated from field: repeated int32 replies = 3; + */ + replies: number[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetInteractionCountsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'likes', + kind: 'scalar', + T: 5 /* ScalarType.INT32 */, + repeated: true, + }, + { + no: 2, + name: 'reposts', + kind: 'scalar', + T: 5 /* ScalarType.INT32 */, + repeated: true, + }, + { + no: 3, + name: 'replies', + kind: 'scalar', + T: 5 /* ScalarType.INT32 */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetInteractionCountsResponse { + return new GetInteractionCountsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetInteractionCountsResponse { + return new GetInteractionCountsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetInteractionCountsResponse { + return new GetInteractionCountsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetInteractionCountsResponse + | PlainMessage + | undefined, + b: + | GetInteractionCountsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetInteractionCountsResponse, a, b) + } +} + +/** + * @generated from message bsky.GetCountsForUsersRequest + */ +export class GetCountsForUsersRequest extends Message { + /** + * @generated from field: repeated string dids = 1; + */ + dids: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetCountsForUsersRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'dids', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetCountsForUsersRequest { + return new GetCountsForUsersRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetCountsForUsersRequest { + return new GetCountsForUsersRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetCountsForUsersRequest { + return new GetCountsForUsersRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetCountsForUsersRequest + | PlainMessage + | undefined, + b: + | GetCountsForUsersRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetCountsForUsersRequest, a, b) + } +} + +/** + * @generated from message bsky.GetCountsForUsersResponse + */ +export class GetCountsForUsersResponse extends Message { + /** + * @generated from field: repeated int32 posts = 1; + */ + posts: number[] = [] + + /** + * @generated from field: repeated int32 reposts = 2; + */ + reposts: number[] = [] + + /** + * @generated from field: repeated int32 following = 3; + */ + following: number[] = [] + + /** + * @generated from field: repeated int32 followers = 4; + */ + followers: number[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetCountsForUsersResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'posts', + kind: 'scalar', + T: 5 /* ScalarType.INT32 */, + repeated: true, + }, + { + no: 2, + name: 'reposts', + kind: 'scalar', + T: 5 /* ScalarType.INT32 */, + repeated: true, + }, + { + no: 3, + name: 'following', + kind: 'scalar', + T: 5 /* ScalarType.INT32 */, + repeated: true, + }, + { + no: 4, + name: 'followers', + kind: 'scalar', + T: 5 /* ScalarType.INT32 */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetCountsForUsersResponse { + return new GetCountsForUsersResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetCountsForUsersResponse { + return new GetCountsForUsersResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetCountsForUsersResponse { + return new GetCountsForUsersResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetCountsForUsersResponse + | PlainMessage + | undefined, + b: + | GetCountsForUsersResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetCountsForUsersResponse, a, b) + } +} + +/** + * - return repost uris where subject uri is subject A + * - `getReposts` list for a post + * + * @generated from message bsky.GetRepostsBySubjectRequest + */ +export class GetRepostsBySubjectRequest extends Message { + /** + * @generated from field: bsky.RecordRef subject = 1; + */ + subject?: RecordRef + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRepostsBySubjectRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'subject', kind: 'message', T: RecordRef }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRepostsBySubjectRequest { + return new GetRepostsBySubjectRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRepostsBySubjectRequest { + return new GetRepostsBySubjectRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRepostsBySubjectRequest { + return new GetRepostsBySubjectRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetRepostsBySubjectRequest + | PlainMessage + | undefined, + b: + | GetRepostsBySubjectRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRepostsBySubjectRequest, a, b) + } +} + +/** + * @generated from message bsky.GetRepostsBySubjectResponse + */ +export class GetRepostsBySubjectResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRepostsBySubjectResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRepostsBySubjectResponse { + return new GetRepostsBySubjectResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRepostsBySubjectResponse { + return new GetRepostsBySubjectResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRepostsBySubjectResponse { + return new GetRepostsBySubjectResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetRepostsBySubjectResponse + | PlainMessage + | undefined, + b: + | GetRepostsBySubjectResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRepostsBySubjectResponse, a, b) + } +} + +/** + * - return repost uris for user A on subject B, C, D... + * - viewer state on posts + * + * @generated from message bsky.GetRepostsByActorAndSubjectsRequest + */ +export class GetRepostsByActorAndSubjectsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: repeated bsky.RecordRef refs = 2; + */ + refs: RecordRef[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRepostsByActorAndSubjectsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'refs', kind: 'message', T: RecordRef, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRepostsByActorAndSubjectsRequest { + return new GetRepostsByActorAndSubjectsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRepostsByActorAndSubjectsRequest { + return new GetRepostsByActorAndSubjectsRequest().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRepostsByActorAndSubjectsRequest { + return new GetRepostsByActorAndSubjectsRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetRepostsByActorAndSubjectsRequest + | PlainMessage + | undefined, + b: + | GetRepostsByActorAndSubjectsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRepostsByActorAndSubjectsRequest, a, b) + } +} + +/** + * @generated from message bsky.RecordRef + */ +export class RecordRef extends Message { + /** + * @generated from field: string uri = 1; + */ + uri = '' + + /** + * @generated from field: string cid = 2; + */ + cid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.RecordRef' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): RecordRef { + return new RecordRef().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): RecordRef { + return new RecordRef().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): RecordRef { + return new RecordRef().fromJsonString(jsonString, options) + } + + static equals( + a: RecordRef | PlainMessage | undefined, + b: RecordRef | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(RecordRef, a, b) + } +} + +/** + * @generated from message bsky.GetRepostsByActorAndSubjectsResponse + */ +export class GetRepostsByActorAndSubjectsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRepostsByActorAndSubjectsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRepostsByActorAndSubjectsResponse { + return new GetRepostsByActorAndSubjectsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRepostsByActorAndSubjectsResponse { + return new GetRepostsByActorAndSubjectsResponse().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRepostsByActorAndSubjectsResponse { + return new GetRepostsByActorAndSubjectsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetRepostsByActorAndSubjectsResponse + | PlainMessage + | undefined, + b: + | GetRepostsByActorAndSubjectsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRepostsByActorAndSubjectsResponse, a, b) + } +} + +/** + * - return recent repost uris for user A + * - `getActorReposts` list for a user + * + * @generated from message bsky.GetActorRepostsRequest + */ +export class GetActorRepostsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorRepostsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorRepostsRequest { + return new GetActorRepostsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorRepostsRequest { + return new GetActorRepostsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorRepostsRequest { + return new GetActorRepostsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetActorRepostsRequest + | PlainMessage + | undefined, + b: + | GetActorRepostsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorRepostsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetActorRepostsResponse + */ +export class GetActorRepostsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorRepostsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorRepostsResponse { + return new GetActorRepostsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorRepostsResponse { + return new GetActorRepostsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorRepostsResponse { + return new GetActorRepostsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetActorRepostsResponse + | PlainMessage + | undefined, + b: + | GetActorRepostsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorRepostsResponse, a, b) + } +} + +/** + * - return actor information for dids A, B, C… + * - profile hydration + * - should this include handles? apply repo takedown? + * + * @generated from message bsky.GetActorsRequest + */ +export class GetActorsRequest extends Message { + /** + * @generated from field: repeated string dids = 1; + */ + dids: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'dids', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorsRequest { + return new GetActorsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorsRequest { + return new GetActorsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorsRequest { + return new GetActorsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetActorsRequest | PlainMessage | undefined, + b: GetActorsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetActorsRequest, a, b) + } +} + +/** + * @generated from message bsky.ActorInfo + */ +export class ActorInfo extends Message { + /** + * @generated from field: bool exists = 1; + */ + exists = false + + /** + * @generated from field: string handle = 2; + */ + handle = '' + + /** + * @generated from field: bsky.Record profile = 3; + */ + profile?: Record + + /** + * @generated from field: bool taken_down = 4; + */ + takenDown = false + + /** + * @generated from field: string takedown_ref = 5; + */ + takedownRef = '' + + /** + * @generated from field: google.protobuf.Timestamp tombstoned_at = 6; + */ + tombstonedAt?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.ActorInfo' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'exists', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: 'handle', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'profile', kind: 'message', T: Record }, + { no: 4, name: 'taken_down', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { + no: 5, + name: 'takedown_ref', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { no: 6, name: 'tombstoned_at', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): ActorInfo { + return new ActorInfo().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): ActorInfo { + return new ActorInfo().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): ActorInfo { + return new ActorInfo().fromJsonString(jsonString, options) + } + + static equals( + a: ActorInfo | PlainMessage | undefined, + b: ActorInfo | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(ActorInfo, a, b) + } +} + +/** + * @generated from message bsky.GetActorsResponse + */ +export class GetActorsResponse extends Message { + /** + * @generated from field: repeated bsky.ActorInfo actors = 1; + */ + actors: ActorInfo[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actors', kind: 'message', T: ActorInfo, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorsResponse { + return new GetActorsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorsResponse { + return new GetActorsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorsResponse { + return new GetActorsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetActorsResponse | PlainMessage | undefined, + b: GetActorsResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetActorsResponse, a, b) + } +} + +/** + * - return did for handle A + * - `resolveHandle` + * - answering queries where the query param is a handle + * + * @generated from message bsky.GetDidsByHandlesRequest + */ +export class GetDidsByHandlesRequest extends Message { + /** + * @generated from field: repeated string handles = 1; + */ + handles: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetDidsByHandlesRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'handles', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetDidsByHandlesRequest { + return new GetDidsByHandlesRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetDidsByHandlesRequest { + return new GetDidsByHandlesRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetDidsByHandlesRequest { + return new GetDidsByHandlesRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetDidsByHandlesRequest + | PlainMessage + | undefined, + b: + | GetDidsByHandlesRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetDidsByHandlesRequest, a, b) + } +} + +/** + * @generated from message bsky.GetDidsByHandlesResponse + */ +export class GetDidsByHandlesResponse extends Message { + /** + * @generated from field: repeated string dids = 1; + */ + dids: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetDidsByHandlesResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'dids', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetDidsByHandlesResponse { + return new GetDidsByHandlesResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetDidsByHandlesResponse { + return new GetDidsByHandlesResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetDidsByHandlesResponse { + return new GetDidsByHandlesResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetDidsByHandlesResponse + | PlainMessage + | undefined, + b: + | GetDidsByHandlesResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetDidsByHandlesResponse, a, b) + } +} + +/** + * - return relationships between user A and users B, C, D... + * - profile hydration + * - block application + * + * @generated from message bsky.GetRelationshipsRequest + */ +export class GetRelationshipsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: repeated string target_dids = 2; + */ + targetDids: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRelationshipsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'target_dids', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRelationshipsRequest { + return new GetRelationshipsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRelationshipsRequest { + return new GetRelationshipsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRelationshipsRequest { + return new GetRelationshipsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetRelationshipsRequest + | PlainMessage + | undefined, + b: + | GetRelationshipsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRelationshipsRequest, a, b) + } +} + +/** + * @generated from message bsky.Relationships + */ +export class Relationships extends Message { + /** + * @generated from field: bool muted = 1; + */ + muted = false + + /** + * @generated from field: string muted_by_list = 2; + */ + mutedByList = '' + + /** + * @generated from field: string blocked_by = 3; + */ + blockedBy = '' + + /** + * @generated from field: string blocking = 4; + */ + blocking = '' + + /** + * @generated from field: string blocked_by_list = 5; + */ + blockedByList = '' + + /** + * @generated from field: string blocking_by_list = 6; + */ + blockingByList = '' + + /** + * @generated from field: string following = 7; + */ + following = '' + + /** + * @generated from field: string followed_by = 8; + */ + followedBy = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.Relationships' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'muted', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { + no: 2, + name: 'muted_by_list', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { no: 3, name: 'blocked_by', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 4, name: 'blocking', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 5, + name: 'blocked_by_list', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { + no: 6, + name: 'blocking_by_list', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { no: 7, name: 'following', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 8, + name: 'followed_by', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): Relationships { + return new Relationships().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): Relationships { + return new Relationships().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): Relationships { + return new Relationships().fromJsonString(jsonString, options) + } + + static equals( + a: Relationships | PlainMessage | undefined, + b: Relationships | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(Relationships, a, b) + } +} + +/** + * @generated from message bsky.GetRelationshipsResponse + */ +export class GetRelationshipsResponse extends Message { + /** + * @generated from field: repeated bsky.Relationships relationships = 1; + */ + relationships: Relationships[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRelationshipsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'relationships', + kind: 'message', + T: Relationships, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRelationshipsResponse { + return new GetRelationshipsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRelationshipsResponse { + return new GetRelationshipsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRelationshipsResponse { + return new GetRelationshipsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetRelationshipsResponse + | PlainMessage + | undefined, + b: + | GetRelationshipsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRelationshipsResponse, a, b) + } +} + +/** + * - return whether a block (bidrectionally and either direct or through a list) exists between two dids + * - enforcing 3rd party block violations + * + * @generated from message bsky.RelationshipPair + */ +export class RelationshipPair extends Message { + /** + * @generated from field: string a = 1; + */ + a = '' + + /** + * @generated from field: string b = 2; + */ + b = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.RelationshipPair' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'a', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'b', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): RelationshipPair { + return new RelationshipPair().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): RelationshipPair { + return new RelationshipPair().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): RelationshipPair { + return new RelationshipPair().fromJsonString(jsonString, options) + } + + static equals( + a: RelationshipPair | PlainMessage | undefined, + b: RelationshipPair | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(RelationshipPair, a, b) + } +} + +/** + * @generated from message bsky.GetBlockExistenceRequest + */ +export class GetBlockExistenceRequest extends Message { + /** + * @generated from field: repeated bsky.RelationshipPair pairs = 1; + */ + pairs: RelationshipPair[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlockExistenceRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'pairs', + kind: 'message', + T: RelationshipPair, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlockExistenceRequest { + return new GetBlockExistenceRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlockExistenceRequest { + return new GetBlockExistenceRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlockExistenceRequest { + return new GetBlockExistenceRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetBlockExistenceRequest + | PlainMessage + | undefined, + b: + | GetBlockExistenceRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlockExistenceRequest, a, b) + } +} + +/** + * @generated from message bsky.GetBlockExistenceResponse + */ +export class GetBlockExistenceResponse extends Message { + /** + * @generated from field: repeated bool exists = 1; + */ + exists: boolean[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlockExistenceResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'exists', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlockExistenceResponse { + return new GetBlockExistenceResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlockExistenceResponse { + return new GetBlockExistenceResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlockExistenceResponse { + return new GetBlockExistenceResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetBlockExistenceResponse + | PlainMessage + | undefined, + b: + | GetBlockExistenceResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlockExistenceResponse, a, b) + } +} + +/** + * @generated from message bsky.ListItemInfo + */ +export class ListItemInfo extends Message { + /** + * @generated from field: string uri = 1; + */ + uri = '' + + /** + * @generated from field: string did = 2; + */ + did = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.ListItemInfo' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): ListItemInfo { + return new ListItemInfo().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): ListItemInfo { + return new ListItemInfo().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): ListItemInfo { + return new ListItemInfo().fromJsonString(jsonString, options) + } + + static equals( + a: ListItemInfo | PlainMessage | undefined, + b: ListItemInfo | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(ListItemInfo, a, b) + } +} + +/** + * - Return dids of users in list A + * - E.g. to view items in one of your mute lists + * + * @generated from message bsky.GetListMembersRequest + */ +export class GetListMembersRequest extends Message { + /** + * @generated from field: string list_uri = 1; + */ + listUri = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListMembersRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'list_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListMembersRequest { + return new GetListMembersRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListMembersRequest { + return new GetListMembersRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListMembersRequest { + return new GetListMembersRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetListMembersRequest | PlainMessage | undefined, + b: GetListMembersRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetListMembersRequest, a, b) + } +} + +/** + * @generated from message bsky.GetListMembersResponse + */ +export class GetListMembersResponse extends Message { + /** + * @generated from field: repeated bsky.ListItemInfo listitems = 1; + */ + listitems: ListItemInfo[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListMembersResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'listitems', + kind: 'message', + T: ListItemInfo, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListMembersResponse { + return new GetListMembersResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListMembersResponse { + return new GetListMembersResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListMembersResponse { + return new GetListMembersResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetListMembersResponse + | PlainMessage + | undefined, + b: + | GetListMembersResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetListMembersResponse, a, b) + } +} + +/** + * - Return list uris where user A in list B, C, D… + * - Used in thread reply gates + * + * @generated from message bsky.GetListMembershipRequest + */ +export class GetListMembershipRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: repeated string list_uris = 2; + */ + listUris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListMembershipRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'list_uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListMembershipRequest { + return new GetListMembershipRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListMembershipRequest { + return new GetListMembershipRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListMembershipRequest { + return new GetListMembershipRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetListMembershipRequest + | PlainMessage + | undefined, + b: + | GetListMembershipRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetListMembershipRequest, a, b) + } +} + +/** + * @generated from message bsky.GetListMembershipResponse + */ +export class GetListMembershipResponse extends Message { + /** + * @generated from field: repeated string listitem_uris = 1; + */ + listitemUris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListMembershipResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'listitem_uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListMembershipResponse { + return new GetListMembershipResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListMembershipResponse { + return new GetListMembershipResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListMembershipResponse { + return new GetListMembershipResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetListMembershipResponse + | PlainMessage + | undefined, + b: + | GetListMembershipResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetListMembershipResponse, a, b) + } +} + +/** + * - Return number of items in list A + * - For aggregate + * + * @generated from message bsky.GetListCountRequest + */ +export class GetListCountRequest extends Message { + /** + * @generated from field: string list_uri = 1; + */ + listUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListCountRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'list_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListCountRequest { + return new GetListCountRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListCountRequest { + return new GetListCountRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListCountRequest { + return new GetListCountRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetListCountRequest | PlainMessage | undefined, + b: GetListCountRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetListCountRequest, a, b) + } +} + +/** + * @generated from message bsky.GetListCountResponse + */ +export class GetListCountResponse extends Message { + /** + * @generated from field: int32 count = 1; + */ + count = 0 + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListCountResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'count', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListCountResponse { + return new GetListCountResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListCountResponse { + return new GetListCountResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListCountResponse { + return new GetListCountResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetListCountResponse | PlainMessage | undefined, + b: GetListCountResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetListCountResponse, a, b) + } +} + +/** + * - return list of uris of lists created by A + * - `getLists` + * + * @generated from message bsky.GetActorListsRequest + */ +export class GetActorListsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorListsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorListsRequest { + return new GetActorListsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorListsRequest { + return new GetActorListsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorListsRequest { + return new GetActorListsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetActorListsRequest | PlainMessage | undefined, + b: GetActorListsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetActorListsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetActorListsResponse + */ +export class GetActorListsResponse extends Message { + /** + * @generated from field: repeated string list_uris = 1; + */ + listUris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorListsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'list_uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorListsResponse { + return new GetActorListsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorListsResponse { + return new GetActorListsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorListsResponse { + return new GetActorListsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetActorListsResponse | PlainMessage | undefined, + b: GetActorListsResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetActorListsResponse, a, b) + } +} + +/** + * - return boolean if user A has muted user B + * - hydrating mute state onto profiles + * + * @generated from message bsky.GetActorMutesActorRequest + */ +export class GetActorMutesActorRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string target_did = 2; + */ + targetDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorMutesActorRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'target_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorMutesActorRequest { + return new GetActorMutesActorRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorMutesActorRequest { + return new GetActorMutesActorRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorMutesActorRequest { + return new GetActorMutesActorRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetActorMutesActorRequest + | PlainMessage + | undefined, + b: + | GetActorMutesActorRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorMutesActorRequest, a, b) + } +} + +/** + * @generated from message bsky.GetActorMutesActorResponse + */ +export class GetActorMutesActorResponse extends Message { + /** + * @generated from field: bool muted = 1; + */ + muted = false + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorMutesActorResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'muted', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorMutesActorResponse { + return new GetActorMutesActorResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorMutesActorResponse { + return new GetActorMutesActorResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorMutesActorResponse { + return new GetActorMutesActorResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetActorMutesActorResponse + | PlainMessage + | undefined, + b: + | GetActorMutesActorResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorMutesActorResponse, a, b) + } +} + +/** + * - return list of user dids of users who A mutes + * - `getMutes` + * + * @generated from message bsky.GetMutesRequest + */ +export class GetMutesRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetMutesRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetMutesRequest { + return new GetMutesRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetMutesRequest { + return new GetMutesRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetMutesRequest { + return new GetMutesRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetMutesRequest | PlainMessage | undefined, + b: GetMutesRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetMutesRequest, a, b) + } +} + +/** + * @generated from message bsky.GetMutesResponse + */ +export class GetMutesResponse extends Message { + /** + * @generated from field: repeated string dids = 1; + */ + dids: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetMutesResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'dids', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetMutesResponse { + return new GetMutesResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetMutesResponse { + return new GetMutesResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetMutesResponse { + return new GetMutesResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetMutesResponse | PlainMessage | undefined, + b: GetMutesResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetMutesResponse, a, b) + } +} + +/** + * - return list uri of *any* list through which user A has muted user B + * - hydrating mute state onto profiles + * - note: we only need *one* uri even if a user is muted by multiple lists + * + * @generated from message bsky.GetActorMutesActorViaListRequest + */ +export class GetActorMutesActorViaListRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string target_did = 2; + */ + targetDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorMutesActorViaListRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'target_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorMutesActorViaListRequest { + return new GetActorMutesActorViaListRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorMutesActorViaListRequest { + return new GetActorMutesActorViaListRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorMutesActorViaListRequest { + return new GetActorMutesActorViaListRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetActorMutesActorViaListRequest + | PlainMessage + | undefined, + b: + | GetActorMutesActorViaListRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorMutesActorViaListRequest, a, b) + } +} + +/** + * @generated from message bsky.GetActorMutesActorViaListResponse + */ +export class GetActorMutesActorViaListResponse extends Message { + /** + * @generated from field: string list_uri = 1; + */ + listUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorMutesActorViaListResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'list_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorMutesActorViaListResponse { + return new GetActorMutesActorViaListResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorMutesActorViaListResponse { + return new GetActorMutesActorViaListResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorMutesActorViaListResponse { + return new GetActorMutesActorViaListResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetActorMutesActorViaListResponse + | PlainMessage + | undefined, + b: + | GetActorMutesActorViaListResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorMutesActorViaListResponse, a, b) + } +} + +/** + * - return boolean if actor A has subscribed to mutelist B + * - list view hydration + * + * @generated from message bsky.GetMutelistSubscriptionRequest + */ +export class GetMutelistSubscriptionRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string list_uri = 2; + */ + listUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetMutelistSubscriptionRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'list_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetMutelistSubscriptionRequest { + return new GetMutelistSubscriptionRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetMutelistSubscriptionRequest { + return new GetMutelistSubscriptionRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetMutelistSubscriptionRequest { + return new GetMutelistSubscriptionRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetMutelistSubscriptionRequest + | PlainMessage + | undefined, + b: + | GetMutelistSubscriptionRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetMutelistSubscriptionRequest, a, b) + } +} + +/** + * @generated from message bsky.GetMutelistSubscriptionResponse + */ +export class GetMutelistSubscriptionResponse extends Message { + /** + * @generated from field: bool subscribed = 1; + */ + subscribed = false + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetMutelistSubscriptionResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'subscribed', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetMutelistSubscriptionResponse { + return new GetMutelistSubscriptionResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetMutelistSubscriptionResponse { + return new GetMutelistSubscriptionResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetMutelistSubscriptionResponse { + return new GetMutelistSubscriptionResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetMutelistSubscriptionResponse + | PlainMessage + | undefined, + b: + | GetMutelistSubscriptionResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetMutelistSubscriptionResponse, a, b) + } +} + +/** + * - return list of list uris of mutelists that A subscribes to + * - `getListMutes` + * + * @generated from message bsky.GetMutelistSubscriptionsRequest + */ +export class GetMutelistSubscriptionsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetMutelistSubscriptionsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetMutelistSubscriptionsRequest { + return new GetMutelistSubscriptionsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetMutelistSubscriptionsRequest { + return new GetMutelistSubscriptionsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetMutelistSubscriptionsRequest { + return new GetMutelistSubscriptionsRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetMutelistSubscriptionsRequest + | PlainMessage + | undefined, + b: + | GetMutelistSubscriptionsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetMutelistSubscriptionsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetMutelistSubscriptionsResponse + */ +export class GetMutelistSubscriptionsResponse extends Message { + /** + * @generated from field: repeated string list_uris = 1; + */ + listUris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetMutelistSubscriptionsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'list_uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetMutelistSubscriptionsResponse { + return new GetMutelistSubscriptionsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetMutelistSubscriptionsResponse { + return new GetMutelistSubscriptionsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetMutelistSubscriptionsResponse { + return new GetMutelistSubscriptionsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetMutelistSubscriptionsResponse + | PlainMessage + | undefined, + b: + | GetMutelistSubscriptionsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetMutelistSubscriptionsResponse, a, b) + } +} + +/** + * - Return block uri if there is a block between users A & B (bidirectional) + * - hydrating (& actioning) block state on profiles + * - handling 3rd party blocks + * + * @generated from message bsky.GetBidirectionalBlockRequest + */ +export class GetBidirectionalBlockRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string target_did = 2; + */ + targetDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBidirectionalBlockRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'target_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBidirectionalBlockRequest { + return new GetBidirectionalBlockRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBidirectionalBlockRequest { + return new GetBidirectionalBlockRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBidirectionalBlockRequest { + return new GetBidirectionalBlockRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetBidirectionalBlockRequest + | PlainMessage + | undefined, + b: + | GetBidirectionalBlockRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBidirectionalBlockRequest, a, b) + } +} + +/** + * @generated from message bsky.GetBidirectionalBlockResponse + */ +export class GetBidirectionalBlockResponse extends Message { + /** + * @generated from field: string block_uri = 1; + */ + blockUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBidirectionalBlockResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'block_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBidirectionalBlockResponse { + return new GetBidirectionalBlockResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBidirectionalBlockResponse { + return new GetBidirectionalBlockResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBidirectionalBlockResponse { + return new GetBidirectionalBlockResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetBidirectionalBlockResponse + | PlainMessage + | undefined, + b: + | GetBidirectionalBlockResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBidirectionalBlockResponse, a, b) + } +} + +/** + * - Return list of block uris and user dids of users who A blocks + * - `getBlocks` + * + * @generated from message bsky.GetBlocksRequest + */ +export class GetBlocksRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlocksRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlocksRequest { + return new GetBlocksRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlocksRequest { + return new GetBlocksRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlocksRequest { + return new GetBlocksRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetBlocksRequest | PlainMessage | undefined, + b: GetBlocksRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetBlocksRequest, a, b) + } +} + +/** + * @generated from message bsky.GetBlocksResponse + */ +export class GetBlocksResponse extends Message { + /** + * @generated from field: repeated string block_uris = 1; + */ + blockUris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlocksResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'block_uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlocksResponse { + return new GetBlocksResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlocksResponse { + return new GetBlocksResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlocksResponse { + return new GetBlocksResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetBlocksResponse | PlainMessage | undefined, + b: GetBlocksResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetBlocksResponse, a, b) + } +} + +/** + * - Return list uri of ***any*** list through which users A & B have a block (bidirectional) + * - hydrating (& actioning) block state on profiles + * - handling 3rd party blocks + * + * @generated from message bsky.GetBidirectionalBlockViaListRequest + */ +export class GetBidirectionalBlockViaListRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string target_did = 2; + */ + targetDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBidirectionalBlockViaListRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'target_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBidirectionalBlockViaListRequest { + return new GetBidirectionalBlockViaListRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBidirectionalBlockViaListRequest { + return new GetBidirectionalBlockViaListRequest().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBidirectionalBlockViaListRequest { + return new GetBidirectionalBlockViaListRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetBidirectionalBlockViaListRequest + | PlainMessage + | undefined, + b: + | GetBidirectionalBlockViaListRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBidirectionalBlockViaListRequest, a, b) + } +} + +/** + * @generated from message bsky.GetBidirectionalBlockViaListResponse + */ +export class GetBidirectionalBlockViaListResponse extends Message { + /** + * @generated from field: string list_uri = 1; + */ + listUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBidirectionalBlockViaListResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'list_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBidirectionalBlockViaListResponse { + return new GetBidirectionalBlockViaListResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBidirectionalBlockViaListResponse { + return new GetBidirectionalBlockViaListResponse().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBidirectionalBlockViaListResponse { + return new GetBidirectionalBlockViaListResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetBidirectionalBlockViaListResponse + | PlainMessage + | undefined, + b: + | GetBidirectionalBlockViaListResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBidirectionalBlockViaListResponse, a, b) + } +} + +/** + * - return boolean if user A has subscribed to blocklist B + * - list view hydration + * + * @generated from message bsky.GetBlocklistSubscriptionRequest + */ +export class GetBlocklistSubscriptionRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string list_uri = 2; + */ + listUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlocklistSubscriptionRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'list_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlocklistSubscriptionRequest { + return new GetBlocklistSubscriptionRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlocklistSubscriptionRequest { + return new GetBlocklistSubscriptionRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlocklistSubscriptionRequest { + return new GetBlocklistSubscriptionRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetBlocklistSubscriptionRequest + | PlainMessage + | undefined, + b: + | GetBlocklistSubscriptionRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlocklistSubscriptionRequest, a, b) + } +} + +/** + * @generated from message bsky.GetBlocklistSubscriptionResponse + */ +export class GetBlocklistSubscriptionResponse extends Message { + /** + * @generated from field: string listblock_uri = 1; + */ + listblockUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlocklistSubscriptionResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'listblock_uri', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlocklistSubscriptionResponse { + return new GetBlocklistSubscriptionResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlocklistSubscriptionResponse { + return new GetBlocklistSubscriptionResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlocklistSubscriptionResponse { + return new GetBlocklistSubscriptionResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetBlocklistSubscriptionResponse + | PlainMessage + | undefined, + b: + | GetBlocklistSubscriptionResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlocklistSubscriptionResponse, a, b) + } +} + +/** + * - return list of list uris of Blockslists that A subscribes to + * - `getListBlocks` + * + * @generated from message bsky.GetBlocklistSubscriptionsRequest + */ +export class GetBlocklistSubscriptionsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlocklistSubscriptionsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlocklistSubscriptionsRequest { + return new GetBlocklistSubscriptionsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlocklistSubscriptionsRequest { + return new GetBlocklistSubscriptionsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlocklistSubscriptionsRequest { + return new GetBlocklistSubscriptionsRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetBlocklistSubscriptionsRequest + | PlainMessage + | undefined, + b: + | GetBlocklistSubscriptionsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlocklistSubscriptionsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetBlocklistSubscriptionsResponse + */ +export class GetBlocklistSubscriptionsResponse extends Message { + /** + * @generated from field: repeated string list_uris = 1; + */ + listUris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlocklistSubscriptionsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'list_uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlocklistSubscriptionsResponse { + return new GetBlocklistSubscriptionsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlocklistSubscriptionsResponse { + return new GetBlocklistSubscriptionsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlocklistSubscriptionsResponse { + return new GetBlocklistSubscriptionsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetBlocklistSubscriptionsResponse + | PlainMessage + | undefined, + b: + | GetBlocklistSubscriptionsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlocklistSubscriptionsResponse, a, b) + } +} + +/** + * - list recent notifications for a user + * - notifications should include a uri for the record that caused the notif & a “reason” for the notification (reply, like, quotepost, etc) + * - this should include both read & unread notifs + * + * @generated from message bsky.GetNotificationsRequest + */ +export class GetNotificationsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetNotificationsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetNotificationsRequest { + return new GetNotificationsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetNotificationsRequest { + return new GetNotificationsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetNotificationsRequest { + return new GetNotificationsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetNotificationsRequest + | PlainMessage + | undefined, + b: + | GetNotificationsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetNotificationsRequest, a, b) + } +} + +/** + * @generated from message bsky.Notification + */ +export class Notification extends Message { + /** + * @generated from field: string recipient_did = 1; + */ + recipientDid = '' + + /** + * @generated from field: string uri = 2; + */ + uri = '' + + /** + * @generated from field: string reason = 3; + */ + reason = '' + + /** + * @generated from field: string reason_subject = 4; + */ + reasonSubject = '' + + /** + * @generated from field: google.protobuf.Timestamp timestamp = 5; + */ + timestamp?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.Notification' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'recipient_did', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { no: 2, name: 'uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'reason', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 4, + name: 'reason_subject', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { no: 5, name: 'timestamp', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): Notification { + return new Notification().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): Notification { + return new Notification().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): Notification { + return new Notification().fromJsonString(jsonString, options) + } + + static equals( + a: Notification | PlainMessage | undefined, + b: Notification | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(Notification, a, b) + } +} + +/** + * @generated from message bsky.GetNotificationsResponse + */ +export class GetNotificationsResponse extends Message { + /** + * @generated from field: repeated bsky.Notification notifications = 1; + */ + notifications: Notification[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetNotificationsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'notifications', + kind: 'message', + T: Notification, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetNotificationsResponse { + return new GetNotificationsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetNotificationsResponse { + return new GetNotificationsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetNotificationsResponse { + return new GetNotificationsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetNotificationsResponse + | PlainMessage + | undefined, + b: + | GetNotificationsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetNotificationsResponse, a, b) + } +} + +/** + * - update a user’s “last seen time” + * - `updateSeen` + * + * @generated from message bsky.UpdateNotificationSeenRequest + */ +export class UpdateNotificationSeenRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: google.protobuf.Timestamp timestamp = 2; + */ + timestamp?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.UpdateNotificationSeenRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'timestamp', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): UpdateNotificationSeenRequest { + return new UpdateNotificationSeenRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): UpdateNotificationSeenRequest { + return new UpdateNotificationSeenRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): UpdateNotificationSeenRequest { + return new UpdateNotificationSeenRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | UpdateNotificationSeenRequest + | PlainMessage + | undefined, + b: + | UpdateNotificationSeenRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(UpdateNotificationSeenRequest, a, b) + } +} + +/** + * @generated from message bsky.UpdateNotificationSeenResponse + */ +export class UpdateNotificationSeenResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.UpdateNotificationSeenResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): UpdateNotificationSeenResponse { + return new UpdateNotificationSeenResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): UpdateNotificationSeenResponse { + return new UpdateNotificationSeenResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): UpdateNotificationSeenResponse { + return new UpdateNotificationSeenResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | UpdateNotificationSeenResponse + | PlainMessage + | undefined, + b: + | UpdateNotificationSeenResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(UpdateNotificationSeenResponse, a, b) + } +} + +/** + * - get a user’s “last seen time” + * - hydrating read state onto notifications + * + * @generated from message bsky.GetNotificationSeenRequest + */ +export class GetNotificationSeenRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetNotificationSeenRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetNotificationSeenRequest { + return new GetNotificationSeenRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetNotificationSeenRequest { + return new GetNotificationSeenRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetNotificationSeenRequest { + return new GetNotificationSeenRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetNotificationSeenRequest + | PlainMessage + | undefined, + b: + | GetNotificationSeenRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetNotificationSeenRequest, a, b) + } +} + +/** + * @generated from message bsky.GetNotificationSeenResponse + */ +export class GetNotificationSeenResponse extends Message { + /** + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetNotificationSeenResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'timestamp', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetNotificationSeenResponse { + return new GetNotificationSeenResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetNotificationSeenResponse { + return new GetNotificationSeenResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetNotificationSeenResponse { + return new GetNotificationSeenResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetNotificationSeenResponse + | PlainMessage + | undefined, + b: + | GetNotificationSeenResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetNotificationSeenResponse, a, b) + } +} + +/** + * - get a count of all unread notifications (notifications after `updateSeen`) + * - `getUnreadCount` + * + * @generated from message bsky.GetUnreadNotificationCountRequest + */ +export class GetUnreadNotificationCountRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetUnreadNotificationCountRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetUnreadNotificationCountRequest { + return new GetUnreadNotificationCountRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetUnreadNotificationCountRequest { + return new GetUnreadNotificationCountRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetUnreadNotificationCountRequest { + return new GetUnreadNotificationCountRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetUnreadNotificationCountRequest + | PlainMessage + | undefined, + b: + | GetUnreadNotificationCountRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetUnreadNotificationCountRequest, a, b) + } +} + +/** + * @generated from message bsky.GetUnreadNotificationCountResponse + */ +export class GetUnreadNotificationCountResponse extends Message { + /** + * @generated from field: int32 count = 1; + */ + count = 0 + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetUnreadNotificationCountResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'count', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetUnreadNotificationCountResponse { + return new GetUnreadNotificationCountResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetUnreadNotificationCountResponse { + return new GetUnreadNotificationCountResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetUnreadNotificationCountResponse { + return new GetUnreadNotificationCountResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetUnreadNotificationCountResponse + | PlainMessage + | undefined, + b: + | GetUnreadNotificationCountResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetUnreadNotificationCountResponse, a, b) + } +} + +/** + * - Return uris of feed generator records created by user A + * - `getActorFeeds` + * + * @generated from message bsky.GetActorFeedsRequest + */ +export class GetActorFeedsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorFeedsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorFeedsRequest { + return new GetActorFeedsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorFeedsRequest { + return new GetActorFeedsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorFeedsRequest { + return new GetActorFeedsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetActorFeedsRequest | PlainMessage | undefined, + b: GetActorFeedsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetActorFeedsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetActorFeedsResponse + */ +export class GetActorFeedsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorFeedsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorFeedsResponse { + return new GetActorFeedsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorFeedsResponse { + return new GetActorFeedsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorFeedsResponse { + return new GetActorFeedsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetActorFeedsResponse | PlainMessage | undefined, + b: GetActorFeedsResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetActorFeedsResponse, a, b) + } +} + +/** + * - Returns a list of suggested feed generator uris for an actor, paginated + * - `getSuggestedFeeds` + * - This is currently just hardcoded in the Appview DB + * + * @generated from message bsky.GetSuggestedFeedsRequest + */ +export class GetSuggestedFeedsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetSuggestedFeedsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetSuggestedFeedsRequest { + return new GetSuggestedFeedsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetSuggestedFeedsRequest { + return new GetSuggestedFeedsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetSuggestedFeedsRequest { + return new GetSuggestedFeedsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetSuggestedFeedsRequest + | PlainMessage + | undefined, + b: + | GetSuggestedFeedsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetSuggestedFeedsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetSuggestedFeedsResponse + */ +export class GetSuggestedFeedsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetSuggestedFeedsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetSuggestedFeedsResponse { + return new GetSuggestedFeedsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetSuggestedFeedsResponse { + return new GetSuggestedFeedsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetSuggestedFeedsResponse { + return new GetSuggestedFeedsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetSuggestedFeedsResponse + | PlainMessage + | undefined, + b: + | GetSuggestedFeedsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetSuggestedFeedsResponse, a, b) + } +} + +/** + * @generated from message bsky.SearchFeedGeneratorsRequest + */ +export class SearchFeedGeneratorsRequest extends Message { + /** + * @generated from field: string query = 1; + */ + query = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.SearchFeedGeneratorsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'query', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SearchFeedGeneratorsRequest { + return new SearchFeedGeneratorsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SearchFeedGeneratorsRequest { + return new SearchFeedGeneratorsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SearchFeedGeneratorsRequest { + return new SearchFeedGeneratorsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | SearchFeedGeneratorsRequest + | PlainMessage + | undefined, + b: + | SearchFeedGeneratorsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(SearchFeedGeneratorsRequest, a, b) + } +} + +/** + * @generated from message bsky.SearchFeedGeneratorsResponse + */ +export class SearchFeedGeneratorsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.SearchFeedGeneratorsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SearchFeedGeneratorsResponse { + return new SearchFeedGeneratorsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SearchFeedGeneratorsResponse { + return new SearchFeedGeneratorsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SearchFeedGeneratorsResponse { + return new SearchFeedGeneratorsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | SearchFeedGeneratorsResponse + | PlainMessage + | undefined, + b: + | SearchFeedGeneratorsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(SearchFeedGeneratorsResponse, a, b) + } +} + +/** + * - Returns feed generator validity and online status with uris A, B, C… + * - Not currently being used, but could be worhthwhile. + * + * @generated from message bsky.GetFeedGeneratorStatusRequest + */ +export class GetFeedGeneratorStatusRequest extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFeedGeneratorStatusRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFeedGeneratorStatusRequest { + return new GetFeedGeneratorStatusRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFeedGeneratorStatusRequest { + return new GetFeedGeneratorStatusRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFeedGeneratorStatusRequest { + return new GetFeedGeneratorStatusRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetFeedGeneratorStatusRequest + | PlainMessage + | undefined, + b: + | GetFeedGeneratorStatusRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetFeedGeneratorStatusRequest, a, b) + } +} + +/** + * @generated from message bsky.GetFeedGeneratorStatusResponse + */ +export class GetFeedGeneratorStatusResponse extends Message { + /** + * @generated from field: repeated string status = 1; + */ + status: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFeedGeneratorStatusResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'status', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFeedGeneratorStatusResponse { + return new GetFeedGeneratorStatusResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFeedGeneratorStatusResponse { + return new GetFeedGeneratorStatusResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFeedGeneratorStatusResponse { + return new GetFeedGeneratorStatusResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetFeedGeneratorStatusResponse + | PlainMessage + | undefined, + b: + | GetFeedGeneratorStatusResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetFeedGeneratorStatusResponse, a, b) + } +} + +/** + * - Returns recent posts authored by a given DID, paginated + * - `getAuthorFeed` + * - Optionally: filter by if a post is/isn’t a reply and if a post has a media object in it + * + * @generated from message bsky.GetAuthorFeedRequest + */ +export class GetAuthorFeedRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + /** + * @generated from field: bsky.FeedType feed_type = 4; + */ + feedType = FeedType.UNSPECIFIED + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetAuthorFeedRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 4, name: 'feed_type', kind: 'enum', T: proto3.getEnumType(FeedType) }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetAuthorFeedRequest { + return new GetAuthorFeedRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetAuthorFeedRequest { + return new GetAuthorFeedRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetAuthorFeedRequest { + return new GetAuthorFeedRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetAuthorFeedRequest | PlainMessage | undefined, + b: GetAuthorFeedRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetAuthorFeedRequest, a, b) + } +} + +/** + * @generated from message bsky.AuthorFeedItem + */ +export class AuthorFeedItem extends Message { + /** + * @generated from field: string uri = 1; + */ + uri = '' + + /** + * @generated from field: string cid = 2; + */ + cid = '' + + /** + * @generated from field: string repost = 3; + */ + repost = '' + + /** + * @generated from field: string repost_cid = 4; + */ + repostCid = '' + + /** + * @generated from field: bool posts_and_author_threads = 5; + */ + postsAndAuthorThreads = false + + /** + * @generated from field: bool posts_no_replies = 6; + */ + postsNoReplies = false + + /** + * @generated from field: bool posts_with_media = 7; + */ + postsWithMedia = false + + /** + * @generated from field: bool is_reply = 8; + */ + isReply = false + + /** + * @generated from field: bool is_repost = 9; + */ + isRepost = false + + /** + * @generated from field: bool is_quote_post = 10; + */ + isQuotePost = false + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.AuthorFeedItem' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'repost', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 4, name: 'repost_cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 5, + name: 'posts_and_author_threads', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { + no: 6, + name: 'posts_no_replies', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { + no: 7, + name: 'posts_with_media', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { no: 8, name: 'is_reply', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { no: 9, name: 'is_repost', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { + no: 10, + name: 'is_quote_post', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): AuthorFeedItem { + return new AuthorFeedItem().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): AuthorFeedItem { + return new AuthorFeedItem().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): AuthorFeedItem { + return new AuthorFeedItem().fromJsonString(jsonString, options) + } + + static equals( + a: AuthorFeedItem | PlainMessage | undefined, + b: AuthorFeedItem | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(AuthorFeedItem, a, b) + } +} + +/** + * @generated from message bsky.GetAuthorFeedResponse + */ +export class GetAuthorFeedResponse extends Message { + /** + * @generated from field: repeated bsky.AuthorFeedItem items = 1; + */ + items: AuthorFeedItem[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetAuthorFeedResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'items', + kind: 'message', + T: AuthorFeedItem, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetAuthorFeedResponse { + return new GetAuthorFeedResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetAuthorFeedResponse { + return new GetAuthorFeedResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetAuthorFeedResponse { + return new GetAuthorFeedResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetAuthorFeedResponse | PlainMessage | undefined, + b: GetAuthorFeedResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetAuthorFeedResponse, a, b) + } +} + +/** + * - Returns recent posts authored by users followed by a given DID, paginated + * - `getTimeline` + * + * @generated from message bsky.GetTimelineRequest + */ +export class GetTimelineRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + /** + * @generated from field: bool exclude_replies = 4; + */ + excludeReplies = false + + /** + * @generated from field: bool exclude_reposts = 5; + */ + excludeReposts = false + + /** + * @generated from field: bool exclude_quotes = 6; + */ + excludeQuotes = false + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetTimelineRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 4, + name: 'exclude_replies', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { + no: 5, + name: 'exclude_reposts', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { + no: 6, + name: 'exclude_quotes', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetTimelineRequest { + return new GetTimelineRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetTimelineRequest { + return new GetTimelineRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetTimelineRequest { + return new GetTimelineRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetTimelineRequest | PlainMessage | undefined, + b: GetTimelineRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetTimelineRequest, a, b) + } +} + +/** + * @generated from message bsky.GetTimelineResponse + */ +export class GetTimelineResponse extends Message { + /** + * @generated from field: repeated bsky.TimelineFeedItem items = 1; + */ + items: TimelineFeedItem[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetTimelineResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'items', + kind: 'message', + T: TimelineFeedItem, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetTimelineResponse { + return new GetTimelineResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetTimelineResponse { + return new GetTimelineResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetTimelineResponse { + return new GetTimelineResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetTimelineResponse | PlainMessage | undefined, + b: GetTimelineResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetTimelineResponse, a, b) + } +} + +/** + * @generated from message bsky.TimelineFeedItem + */ +export class TimelineFeedItem extends Message { + /** + * @generated from field: string uri = 1; + */ + uri = '' + + /** + * @generated from field: string cid = 2; + */ + cid = '' + + /** + * @generated from field: string repost = 3; + */ + repost = '' + + /** + * @generated from field: string repost_cid = 4; + */ + repostCid = '' + + /** + * @generated from field: bool is_reply = 5; + */ + isReply = false + + /** + * @generated from field: bool is_repost = 6; + */ + isRepost = false + + /** + * @generated from field: bool is_quote_post = 7; + */ + isQuotePost = false + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.TimelineFeedItem' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'repost', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 4, name: 'repost_cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 5, name: 'is_reply', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { no: 6, name: 'is_repost', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { + no: 7, + name: 'is_quote_post', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): TimelineFeedItem { + return new TimelineFeedItem().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): TimelineFeedItem { + return new TimelineFeedItem().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): TimelineFeedItem { + return new TimelineFeedItem().fromJsonString(jsonString, options) + } + + static equals( + a: TimelineFeedItem | PlainMessage | undefined, + b: TimelineFeedItem | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(TimelineFeedItem, a, b) + } +} + +/** + * - Return recent post uris from users in list A + * - `getListFeed` + * - (This is essentially the same as `getTimeline` but instead of follows of a did, it is list items of a list) + * + * @generated from message bsky.GetListFeedRequest + */ +export class GetListFeedRequest extends Message { + /** + * @generated from field: string list_uri = 1; + */ + listUri = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + /** + * @generated from field: bool exclude_replies = 4; + */ + excludeReplies = false + + /** + * @generated from field: bool exclude_reposts = 5; + */ + excludeReposts = false + + /** + * @generated from field: bool exclude_quotes = 6; + */ + excludeQuotes = false + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListFeedRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'list_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 4, + name: 'exclude_replies', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { + no: 5, + name: 'exclude_reposts', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { + no: 6, + name: 'exclude_quotes', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListFeedRequest { + return new GetListFeedRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListFeedRequest { + return new GetListFeedRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListFeedRequest { + return new GetListFeedRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetListFeedRequest | PlainMessage | undefined, + b: GetListFeedRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetListFeedRequest, a, b) + } +} + +/** + * @generated from message bsky.GetListFeedResponse + */ +export class GetListFeedResponse extends Message { + /** + * @generated from field: repeated bsky.TimelineFeedItem items = 1; + */ + items: TimelineFeedItem[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetListFeedResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'items', + kind: 'message', + T: TimelineFeedItem, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetListFeedResponse { + return new GetListFeedResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetListFeedResponse { + return new GetListFeedResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetListFeedResponse { + return new GetListFeedResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetListFeedResponse | PlainMessage | undefined, + b: GetListFeedResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetListFeedResponse, a, b) + } +} + +/** + * Return posts uris of any replies N levels above or M levels below post A + * + * @generated from message bsky.GetThreadRequest + */ +export class GetThreadRequest extends Message { + /** + * @generated from field: string post_uri = 1; + */ + postUri = '' + + /** + * @generated from field: int32 above = 2; + */ + above = 0 + + /** + * @generated from field: int32 below = 3; + */ + below = 0 + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetThreadRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'post_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'above', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'below', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetThreadRequest { + return new GetThreadRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetThreadRequest { + return new GetThreadRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetThreadRequest { + return new GetThreadRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetThreadRequest | PlainMessage | undefined, + b: GetThreadRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetThreadRequest, a, b) + } +} + +/** + * @generated from message bsky.GetThreadResponse + */ +export class GetThreadResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetThreadResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetThreadResponse { + return new GetThreadResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetThreadResponse { + return new GetThreadResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetThreadResponse { + return new GetThreadResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetThreadResponse | PlainMessage | undefined, + b: GetThreadResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetThreadResponse, a, b) + } +} + +/** + * - Return DIDs of actors matching term, paginated + * - `searchActors` skeleton + * + * @generated from message bsky.SearchActorsRequest + */ +export class SearchActorsRequest extends Message { + /** + * @generated from field: string term = 1; + */ + term = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.SearchActorsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'term', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SearchActorsRequest { + return new SearchActorsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SearchActorsRequest { + return new SearchActorsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SearchActorsRequest { + return new SearchActorsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: SearchActorsRequest | PlainMessage | undefined, + b: SearchActorsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(SearchActorsRequest, a, b) + } +} + +/** + * @generated from message bsky.SearchActorsResponse + */ +export class SearchActorsResponse extends Message { + /** + * @generated from field: repeated string dids = 1; + */ + dids: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.SearchActorsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'dids', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SearchActorsResponse { + return new SearchActorsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SearchActorsResponse { + return new SearchActorsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SearchActorsResponse { + return new SearchActorsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: SearchActorsResponse | PlainMessage | undefined, + b: SearchActorsResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(SearchActorsResponse, a, b) + } +} + +/** + * - Return uris of posts matching term, paginated + * - `searchPosts` skeleton + * + * @generated from message bsky.SearchPostsRequest + */ +export class SearchPostsRequest extends Message { + /** + * @generated from field: string term = 1; + */ + term = '' + + /** + * @generated from field: int32 limit = 2; + */ + limit = 0 + + /** + * @generated from field: string cursor = 3; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.SearchPostsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'term', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SearchPostsRequest { + return new SearchPostsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SearchPostsRequest { + return new SearchPostsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SearchPostsRequest { + return new SearchPostsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: SearchPostsRequest | PlainMessage | undefined, + b: SearchPostsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(SearchPostsRequest, a, b) + } +} + +/** + * @generated from message bsky.SearchPostsResponse + */ +export class SearchPostsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.SearchPostsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SearchPostsResponse { + return new SearchPostsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SearchPostsResponse { + return new SearchPostsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SearchPostsResponse { + return new SearchPostsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: SearchPostsResponse | PlainMessage | undefined, + b: SearchPostsResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(SearchPostsResponse, a, b) + } +} + +/** + * - Return DIDs of suggested follows for a user, excluding anyone they already follow + * - `getSuggestions`, `getSuggestedFollowsByActor` + * + * @generated from message bsky.GetFollowSuggestionsRequest + */ +export class GetFollowSuggestionsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string relative_to_did = 2; + */ + relativeToDid = '' + + /** + * @generated from field: int32 limit = 3; + */ + limit = 0 + + /** + * @generated from field: string cursor = 4; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFollowSuggestionsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'relative_to_did', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { no: 3, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 4, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFollowSuggestionsRequest { + return new GetFollowSuggestionsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFollowSuggestionsRequest { + return new GetFollowSuggestionsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFollowSuggestionsRequest { + return new GetFollowSuggestionsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetFollowSuggestionsRequest + | PlainMessage + | undefined, + b: + | GetFollowSuggestionsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetFollowSuggestionsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetFollowSuggestionsResponse + */ +export class GetFollowSuggestionsResponse extends Message { + /** + * @generated from field: repeated string dids = 1; + */ + dids: string[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetFollowSuggestionsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'dids', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetFollowSuggestionsResponse { + return new GetFollowSuggestionsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetFollowSuggestionsResponse { + return new GetFollowSuggestionsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetFollowSuggestionsResponse { + return new GetFollowSuggestionsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetFollowSuggestionsResponse + | PlainMessage + | undefined, + b: + | GetFollowSuggestionsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetFollowSuggestionsResponse, a, b) + } +} + +/** + * @generated from message bsky.SuggestedEntity + */ +export class SuggestedEntity extends Message { + /** + * @generated from field: string tag = 1; + */ + tag = '' + + /** + * @generated from field: string subject = 2; + */ + subject = '' + + /** + * @generated from field: string subject_type = 3; + */ + subjectType = '' + + /** + * @generated from field: int64 priority = 4; + */ + priority = protoInt64.zero + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.SuggestedEntity' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'tag', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'subject', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 3, + name: 'subject_type', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { no: 4, name: 'priority', kind: 'scalar', T: 3 /* ScalarType.INT64 */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SuggestedEntity { + return new SuggestedEntity().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SuggestedEntity { + return new SuggestedEntity().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SuggestedEntity { + return new SuggestedEntity().fromJsonString(jsonString, options) + } + + static equals( + a: SuggestedEntity | PlainMessage | undefined, + b: SuggestedEntity | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(SuggestedEntity, a, b) + } +} + +/** + * @generated from message bsky.GetSuggestedEntitiesRequest + */ +export class GetSuggestedEntitiesRequest extends Message { + /** + * @generated from field: int32 limit = 1; + */ + limit = 0 + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetSuggestedEntitiesRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'limit', kind: 'scalar', T: 5 /* ScalarType.INT32 */ }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetSuggestedEntitiesRequest { + return new GetSuggestedEntitiesRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetSuggestedEntitiesRequest { + return new GetSuggestedEntitiesRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetSuggestedEntitiesRequest { + return new GetSuggestedEntitiesRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetSuggestedEntitiesRequest + | PlainMessage + | undefined, + b: + | GetSuggestedEntitiesRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetSuggestedEntitiesRequest, a, b) + } +} + +/** + * @generated from message bsky.GetSuggestedEntitiesResponse + */ +export class GetSuggestedEntitiesResponse extends Message { + /** + * @generated from field: repeated bsky.SuggestedEntity entities = 1; + */ + entities: SuggestedEntity[] = [] + + /** + * @generated from field: string cursor = 2; + */ + cursor = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetSuggestedEntitiesResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'entities', + kind: 'message', + T: SuggestedEntity, + repeated: true, + }, + { no: 2, name: 'cursor', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetSuggestedEntitiesResponse { + return new GetSuggestedEntitiesResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetSuggestedEntitiesResponse { + return new GetSuggestedEntitiesResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetSuggestedEntitiesResponse { + return new GetSuggestedEntitiesResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | GetSuggestedEntitiesResponse + | PlainMessage + | undefined, + b: + | GetSuggestedEntitiesResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetSuggestedEntitiesResponse, a, b) + } +} + +/** + * - Return post reply count with uris A, B, C… + * - All feed hydration + * + * @generated from message bsky.GetPostReplyCountsRequest + */ +export class GetPostReplyCountsRequest extends Message { + /** + * @generated from field: repeated bsky.RecordRef refs = 1; + */ + refs: RecordRef[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetPostReplyCountsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'refs', kind: 'message', T: RecordRef, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetPostReplyCountsRequest { + return new GetPostReplyCountsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetPostReplyCountsRequest { + return new GetPostReplyCountsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetPostReplyCountsRequest { + return new GetPostReplyCountsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetPostReplyCountsRequest + | PlainMessage + | undefined, + b: + | GetPostReplyCountsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetPostReplyCountsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetPostReplyCountsResponse + */ +export class GetPostReplyCountsResponse extends Message { + /** + * @generated from field: repeated int32 counts = 1; + */ + counts: number[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetPostReplyCountsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'counts', + kind: 'scalar', + T: 5 /* ScalarType.INT32 */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetPostReplyCountsResponse { + return new GetPostReplyCountsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetPostReplyCountsResponse { + return new GetPostReplyCountsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetPostReplyCountsResponse { + return new GetPostReplyCountsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetPostReplyCountsResponse + | PlainMessage + | undefined, + b: + | GetPostReplyCountsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetPostReplyCountsResponse, a, b) + } +} + +/** + * - Get all labels on a subjects A, B, C (uri or did) issued by dids D, E, F… + * - label hydration on nearly every view + * + * @generated from message bsky.GetLabelsRequest + */ +export class GetLabelsRequest extends Message { + /** + * @generated from field: repeated string subjects = 1; + */ + subjects: string[] = [] + + /** + * @generated from field: repeated string issuers = 2; + */ + issuers: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLabelsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'subjects', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + { + no: 2, + name: 'issuers', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLabelsRequest { + return new GetLabelsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLabelsRequest { + return new GetLabelsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLabelsRequest { + return new GetLabelsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetLabelsRequest | PlainMessage | undefined, + b: GetLabelsRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetLabelsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetLabelsResponse + */ +export class GetLabelsResponse extends Message { + /** + * @generated from field: repeated bytes labels = 1; + */ + labels: Uint8Array[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLabelsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'labels', + kind: 'scalar', + T: 12 /* ScalarType.BYTES */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLabelsResponse { + return new GetLabelsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLabelsResponse { + return new GetLabelsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLabelsResponse { + return new GetLabelsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetLabelsResponse | PlainMessage | undefined, + b: GetLabelsResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetLabelsResponse, a, b) + } +} + +/** + * - Latest repo rev of user w/ DID + * - Read-after-write header in`getProfile`, `getProfiles`, `getActorLikes`, `getAuthorFeed`, `getListFeed`, `getPostThread`, `getTimeline`. Could it be view dependent? + * + * @generated from message bsky.GetLatestRevRequest + */ +export class GetLatestRevRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLatestRevRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLatestRevRequest { + return new GetLatestRevRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLatestRevRequest { + return new GetLatestRevRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLatestRevRequest { + return new GetLatestRevRequest().fromJsonString(jsonString, options) + } + + static equals( + a: GetLatestRevRequest | PlainMessage | undefined, + b: GetLatestRevRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetLatestRevRequest, a, b) + } +} + +/** + * @generated from message bsky.GetLatestRevResponse + */ +export class GetLatestRevResponse extends Message { + /** + * @generated from field: string rev = 1; + */ + rev = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLatestRevResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'rev', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLatestRevResponse { + return new GetLatestRevResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLatestRevResponse { + return new GetLatestRevResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLatestRevResponse { + return new GetLatestRevResponse().fromJsonString(jsonString, options) + } + + static equals( + a: GetLatestRevResponse | PlainMessage | undefined, + b: GetLatestRevResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(GetLatestRevResponse, a, b) + } +} + +/** + * @generated from message bsky.GetIdentityByDidRequest + */ +export class GetIdentityByDidRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetIdentityByDidRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetIdentityByDidRequest { + return new GetIdentityByDidRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetIdentityByDidRequest { + return new GetIdentityByDidRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetIdentityByDidRequest { + return new GetIdentityByDidRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetIdentityByDidRequest + | PlainMessage + | undefined, + b: + | GetIdentityByDidRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetIdentityByDidRequest, a, b) + } +} + +/** + * @generated from message bsky.GetIdentityByDidResponse + */ +export class GetIdentityByDidResponse extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + /** + * @generated from field: string handle = 2; + */ + handle = '' + + /** + * @generated from field: bytes keys = 3; + */ + keys = new Uint8Array(0) + + /** + * @generated from field: bytes services = 4; + */ + services = new Uint8Array(0) + + /** + * @generated from field: google.protobuf.Timestamp updated = 5; + */ + updated?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetIdentityByDidResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'handle', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'keys', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 4, name: 'services', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 5, name: 'updated', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetIdentityByDidResponse { + return new GetIdentityByDidResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetIdentityByDidResponse { + return new GetIdentityByDidResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetIdentityByDidResponse { + return new GetIdentityByDidResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetIdentityByDidResponse + | PlainMessage + | undefined, + b: + | GetIdentityByDidResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetIdentityByDidResponse, a, b) + } +} + +/** + * @generated from message bsky.GetIdentityByHandleRequest + */ +export class GetIdentityByHandleRequest extends Message { + /** + * @generated from field: string handle = 1; + */ + handle = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetIdentityByHandleRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'handle', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetIdentityByHandleRequest { + return new GetIdentityByHandleRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetIdentityByHandleRequest { + return new GetIdentityByHandleRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetIdentityByHandleRequest { + return new GetIdentityByHandleRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetIdentityByHandleRequest + | PlainMessage + | undefined, + b: + | GetIdentityByHandleRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetIdentityByHandleRequest, a, b) + } +} + +/** + * @generated from message bsky.GetIdentityByHandleResponse + */ +export class GetIdentityByHandleResponse extends Message { + /** + * @generated from field: string handle = 1; + */ + handle = '' + + /** + * @generated from field: string did = 2; + */ + did = '' + + /** + * @generated from field: bytes keys = 3; + */ + keys = new Uint8Array(0) + + /** + * @generated from field: bytes services = 4; + */ + services = new Uint8Array(0) + + /** + * @generated from field: google.protobuf.Timestamp updated = 5; + */ + updated?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetIdentityByHandleResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'handle', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'keys', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 4, name: 'services', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 5, name: 'updated', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetIdentityByHandleResponse { + return new GetIdentityByHandleResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetIdentityByHandleResponse { + return new GetIdentityByHandleResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetIdentityByHandleResponse { + return new GetIdentityByHandleResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetIdentityByHandleResponse + | PlainMessage + | undefined, + b: + | GetIdentityByHandleResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetIdentityByHandleResponse, a, b) + } +} + +/** + * @generated from message bsky.GetBlobTakedownRequest + */ +export class GetBlobTakedownRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + /** + * @generated from field: string cid = 2; + */ + cid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlobTakedownRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlobTakedownRequest { + return new GetBlobTakedownRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlobTakedownRequest { + return new GetBlobTakedownRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlobTakedownRequest { + return new GetBlobTakedownRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetBlobTakedownRequest + | PlainMessage + | undefined, + b: + | GetBlobTakedownRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlobTakedownRequest, a, b) + } +} + +/** + * @generated from message bsky.GetBlobTakedownResponse + */ +export class GetBlobTakedownResponse extends Message { + /** + * @generated from field: bool taken_down = 1; + */ + takenDown = false + + /** + * @generated from field: string takedown_ref = 2; + */ + takedownRef = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetBlobTakedownResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'taken_down', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { + no: 2, + name: 'takedown_ref', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetBlobTakedownResponse { + return new GetBlobTakedownResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetBlobTakedownResponse { + return new GetBlobTakedownResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetBlobTakedownResponse { + return new GetBlobTakedownResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetBlobTakedownResponse + | PlainMessage + | undefined, + b: + | GetBlobTakedownResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetBlobTakedownResponse, a, b) + } +} + +/** + * @generated from message bsky.GetActorTakedownRequest + */ +export class GetActorTakedownRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorTakedownRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorTakedownRequest { + return new GetActorTakedownRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorTakedownRequest { + return new GetActorTakedownRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorTakedownRequest { + return new GetActorTakedownRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetActorTakedownRequest + | PlainMessage + | undefined, + b: + | GetActorTakedownRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorTakedownRequest, a, b) + } +} + +/** + * @generated from message bsky.GetActorTakedownResponse + */ +export class GetActorTakedownResponse extends Message { + /** + * @generated from field: bool taken_down = 1; + */ + takenDown = false + + /** + * @generated from field: string takedown_ref = 2; + */ + takedownRef = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetActorTakedownResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'taken_down', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { + no: 2, + name: 'takedown_ref', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetActorTakedownResponse { + return new GetActorTakedownResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetActorTakedownResponse { + return new GetActorTakedownResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetActorTakedownResponse { + return new GetActorTakedownResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetActorTakedownResponse + | PlainMessage + | undefined, + b: + | GetActorTakedownResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetActorTakedownResponse, a, b) + } +} + +/** + * @generated from message bsky.GetRecordTakedownRequest + */ +export class GetRecordTakedownRequest extends Message { + /** + * @generated from field: string record_uri = 1; + */ + recordUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRecordTakedownRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'record_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRecordTakedownRequest { + return new GetRecordTakedownRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRecordTakedownRequest { + return new GetRecordTakedownRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRecordTakedownRequest { + return new GetRecordTakedownRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetRecordTakedownRequest + | PlainMessage + | undefined, + b: + | GetRecordTakedownRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRecordTakedownRequest, a, b) + } +} + +/** + * @generated from message bsky.GetRecordTakedownResponse + */ +export class GetRecordTakedownResponse extends Message { + /** + * @generated from field: bool taken_down = 1; + */ + takenDown = false + + /** + * @generated from field: string takedown_ref = 2; + */ + takedownRef = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetRecordTakedownResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'taken_down', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, + { + no: 2, + name: 'takedown_ref', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetRecordTakedownResponse { + return new GetRecordTakedownResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetRecordTakedownResponse { + return new GetRecordTakedownResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetRecordTakedownResponse { + return new GetRecordTakedownResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetRecordTakedownResponse + | PlainMessage + | undefined, + b: + | GetRecordTakedownResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetRecordTakedownResponse, a, b) + } +} + +/** + * Ping + * + * @generated from message bsky.PingRequest + */ +export class PingRequest extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.PingRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): PingRequest { + return new PingRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): PingRequest { + return new PingRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): PingRequest { + return new PingRequest().fromJsonString(jsonString, options) + } + + static equals( + a: PingRequest | PlainMessage | undefined, + b: PingRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(PingRequest, a, b) + } +} + +/** + * @generated from message bsky.PingResponse + */ +export class PingResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.PingResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): PingResponse { + return new PingResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): PingResponse { + return new PingResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): PingResponse { + return new PingResponse().fromJsonString(jsonString, options) + } + + static equals( + a: PingResponse | PlainMessage | undefined, + b: PingResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(PingResponse, a, b) + } +} + +/** + * @generated from message bsky.TakedownActorRequest + */ +export class TakedownActorRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + /** + * @generated from field: string ref = 2; + */ + ref = '' + + /** + * @generated from field: google.protobuf.Timestamp seen = 3; + */ + seen?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.TakedownActorRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'ref', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'seen', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): TakedownActorRequest { + return new TakedownActorRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): TakedownActorRequest { + return new TakedownActorRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): TakedownActorRequest { + return new TakedownActorRequest().fromJsonString(jsonString, options) + } + + static equals( + a: TakedownActorRequest | PlainMessage | undefined, + b: TakedownActorRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(TakedownActorRequest, a, b) + } +} + +/** + * @generated from message bsky.TakedownActorResponse + */ +export class TakedownActorResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.TakedownActorResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): TakedownActorResponse { + return new TakedownActorResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): TakedownActorResponse { + return new TakedownActorResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): TakedownActorResponse { + return new TakedownActorResponse().fromJsonString(jsonString, options) + } + + static equals( + a: TakedownActorResponse | PlainMessage | undefined, + b: TakedownActorResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(TakedownActorResponse, a, b) + } +} + +/** + * @generated from message bsky.UntakedownActorRequest + */ +export class UntakedownActorRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + /** + * @generated from field: google.protobuf.Timestamp seen = 2; + */ + seen?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.UntakedownActorRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'seen', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): UntakedownActorRequest { + return new UntakedownActorRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): UntakedownActorRequest { + return new UntakedownActorRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): UntakedownActorRequest { + return new UntakedownActorRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | UntakedownActorRequest + | PlainMessage + | undefined, + b: + | UntakedownActorRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(UntakedownActorRequest, a, b) + } +} + +/** + * @generated from message bsky.UntakedownActorResponse + */ +export class UntakedownActorResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.UntakedownActorResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): UntakedownActorResponse { + return new UntakedownActorResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): UntakedownActorResponse { + return new UntakedownActorResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): UntakedownActorResponse { + return new UntakedownActorResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | UntakedownActorResponse + | PlainMessage + | undefined, + b: + | UntakedownActorResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(UntakedownActorResponse, a, b) + } +} + +/** + * @generated from message bsky.TakedownBlobRequest + */ +export class TakedownBlobRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + /** + * @generated from field: string cid = 2; + */ + cid = '' + + /** + * @generated from field: string ref = 3; + */ + ref = '' + + /** + * @generated from field: google.protobuf.Timestamp seen = 4; + */ + seen?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.TakedownBlobRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'ref', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 4, name: 'seen', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): TakedownBlobRequest { + return new TakedownBlobRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): TakedownBlobRequest { + return new TakedownBlobRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): TakedownBlobRequest { + return new TakedownBlobRequest().fromJsonString(jsonString, options) + } + + static equals( + a: TakedownBlobRequest | PlainMessage | undefined, + b: TakedownBlobRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(TakedownBlobRequest, a, b) + } +} + +/** + * @generated from message bsky.TakedownBlobResponse + */ +export class TakedownBlobResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.TakedownBlobResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): TakedownBlobResponse { + return new TakedownBlobResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): TakedownBlobResponse { + return new TakedownBlobResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): TakedownBlobResponse { + return new TakedownBlobResponse().fromJsonString(jsonString, options) + } + + static equals( + a: TakedownBlobResponse | PlainMessage | undefined, + b: TakedownBlobResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(TakedownBlobResponse, a, b) + } +} + +/** + * @generated from message bsky.UntakedownBlobRequest + */ +export class UntakedownBlobRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + /** + * @generated from field: string cid = 2; + */ + cid = '' + + /** + * @generated from field: google.protobuf.Timestamp seen = 3; + */ + seen?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.UntakedownBlobRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'cid', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'seen', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): UntakedownBlobRequest { + return new UntakedownBlobRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): UntakedownBlobRequest { + return new UntakedownBlobRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): UntakedownBlobRequest { + return new UntakedownBlobRequest().fromJsonString(jsonString, options) + } + + static equals( + a: UntakedownBlobRequest | PlainMessage | undefined, + b: UntakedownBlobRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(UntakedownBlobRequest, a, b) + } +} + +/** + * @generated from message bsky.UntakedownBlobResponse + */ +export class UntakedownBlobResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.UntakedownBlobResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): UntakedownBlobResponse { + return new UntakedownBlobResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): UntakedownBlobResponse { + return new UntakedownBlobResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): UntakedownBlobResponse { + return new UntakedownBlobResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | UntakedownBlobResponse + | PlainMessage + | undefined, + b: + | UntakedownBlobResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(UntakedownBlobResponse, a, b) + } +} + +/** + * @generated from message bsky.TakedownRecordRequest + */ +export class TakedownRecordRequest extends Message { + /** + * @generated from field: string record_uri = 1; + */ + recordUri = '' + + /** + * @generated from field: string ref = 2; + */ + ref = '' + + /** + * @generated from field: google.protobuf.Timestamp seen = 3; + */ + seen?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.TakedownRecordRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'record_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'ref', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'seen', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): TakedownRecordRequest { + return new TakedownRecordRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): TakedownRecordRequest { + return new TakedownRecordRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): TakedownRecordRequest { + return new TakedownRecordRequest().fromJsonString(jsonString, options) + } + + static equals( + a: TakedownRecordRequest | PlainMessage | undefined, + b: TakedownRecordRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(TakedownRecordRequest, a, b) + } +} + +/** + * @generated from message bsky.TakedownRecordResponse + */ +export class TakedownRecordResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.TakedownRecordResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): TakedownRecordResponse { + return new TakedownRecordResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): TakedownRecordResponse { + return new TakedownRecordResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): TakedownRecordResponse { + return new TakedownRecordResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | TakedownRecordResponse + | PlainMessage + | undefined, + b: + | TakedownRecordResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(TakedownRecordResponse, a, b) + } +} + +/** + * @generated from message bsky.UntakedownRecordRequest + */ +export class UntakedownRecordRequest extends Message { + /** + * @generated from field: string record_uri = 1; + */ + recordUri = '' + + /** + * @generated from field: google.protobuf.Timestamp seen = 2; + */ + seen?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.UntakedownRecordRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'record_uri', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'seen', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): UntakedownRecordRequest { + return new UntakedownRecordRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): UntakedownRecordRequest { + return new UntakedownRecordRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): UntakedownRecordRequest { + return new UntakedownRecordRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | UntakedownRecordRequest + | PlainMessage + | undefined, + b: + | UntakedownRecordRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(UntakedownRecordRequest, a, b) + } +} + +/** + * @generated from message bsky.UntakedownRecordResponse + */ +export class UntakedownRecordResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.UntakedownRecordResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): UntakedownRecordResponse { + return new UntakedownRecordResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): UntakedownRecordResponse { + return new UntakedownRecordResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): UntakedownRecordResponse { + return new UntakedownRecordResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | UntakedownRecordResponse + | PlainMessage + | undefined, + b: + | UntakedownRecordResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(UntakedownRecordResponse, a, b) + } +} + +/** + * @generated from message bsky.CreateActorMuteRequest + */ +export class CreateActorMuteRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string subject_did = 2; + */ + subjectDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.CreateActorMuteRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'subject_did', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): CreateActorMuteRequest { + return new CreateActorMuteRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): CreateActorMuteRequest { + return new CreateActorMuteRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): CreateActorMuteRequest { + return new CreateActorMuteRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | CreateActorMuteRequest + | PlainMessage + | undefined, + b: + | CreateActorMuteRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(CreateActorMuteRequest, a, b) + } +} + +/** + * @generated from message bsky.CreateActorMuteResponse + */ +export class CreateActorMuteResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.CreateActorMuteResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): CreateActorMuteResponse { + return new CreateActorMuteResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): CreateActorMuteResponse { + return new CreateActorMuteResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): CreateActorMuteResponse { + return new CreateActorMuteResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | CreateActorMuteResponse + | PlainMessage + | undefined, + b: + | CreateActorMuteResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(CreateActorMuteResponse, a, b) + } +} + +/** + * @generated from message bsky.DeleteActorMuteRequest + */ +export class DeleteActorMuteRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string subject_did = 2; + */ + subjectDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.DeleteActorMuteRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'subject_did', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): DeleteActorMuteRequest { + return new DeleteActorMuteRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): DeleteActorMuteRequest { + return new DeleteActorMuteRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): DeleteActorMuteRequest { + return new DeleteActorMuteRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | DeleteActorMuteRequest + | PlainMessage + | undefined, + b: + | DeleteActorMuteRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(DeleteActorMuteRequest, a, b) + } +} + +/** + * @generated from message bsky.DeleteActorMuteResponse + */ +export class DeleteActorMuteResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.DeleteActorMuteResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): DeleteActorMuteResponse { + return new DeleteActorMuteResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): DeleteActorMuteResponse { + return new DeleteActorMuteResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): DeleteActorMuteResponse { + return new DeleteActorMuteResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | DeleteActorMuteResponse + | PlainMessage + | undefined, + b: + | DeleteActorMuteResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(DeleteActorMuteResponse, a, b) + } +} + +/** + * @generated from message bsky.ClearActorMutesRequest + */ +export class ClearActorMutesRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.ClearActorMutesRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): ClearActorMutesRequest { + return new ClearActorMutesRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): ClearActorMutesRequest { + return new ClearActorMutesRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): ClearActorMutesRequest { + return new ClearActorMutesRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | ClearActorMutesRequest + | PlainMessage + | undefined, + b: + | ClearActorMutesRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(ClearActorMutesRequest, a, b) + } +} + +/** + * @generated from message bsky.ClearActorMutesResponse + */ +export class ClearActorMutesResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.ClearActorMutesResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): ClearActorMutesResponse { + return new ClearActorMutesResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): ClearActorMutesResponse { + return new ClearActorMutesResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): ClearActorMutesResponse { + return new ClearActorMutesResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | ClearActorMutesResponse + | PlainMessage + | undefined, + b: + | ClearActorMutesResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(ClearActorMutesResponse, a, b) + } +} + +/** + * @generated from message bsky.CreateActorMutelistSubscriptionRequest + */ +export class CreateActorMutelistSubscriptionRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string subject_uri = 2; + */ + subjectUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.CreateActorMutelistSubscriptionRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'subject_uri', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): CreateActorMutelistSubscriptionRequest { + return new CreateActorMutelistSubscriptionRequest().fromBinary( + bytes, + options, + ) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): CreateActorMutelistSubscriptionRequest { + return new CreateActorMutelistSubscriptionRequest().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): CreateActorMutelistSubscriptionRequest { + return new CreateActorMutelistSubscriptionRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | CreateActorMutelistSubscriptionRequest + | PlainMessage + | undefined, + b: + | CreateActorMutelistSubscriptionRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(CreateActorMutelistSubscriptionRequest, a, b) + } +} + +/** + * @generated from message bsky.CreateActorMutelistSubscriptionResponse + */ +export class CreateActorMutelistSubscriptionResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.CreateActorMutelistSubscriptionResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): CreateActorMutelistSubscriptionResponse { + return new CreateActorMutelistSubscriptionResponse().fromBinary( + bytes, + options, + ) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): CreateActorMutelistSubscriptionResponse { + return new CreateActorMutelistSubscriptionResponse().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): CreateActorMutelistSubscriptionResponse { + return new CreateActorMutelistSubscriptionResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | CreateActorMutelistSubscriptionResponse + | PlainMessage + | undefined, + b: + | CreateActorMutelistSubscriptionResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(CreateActorMutelistSubscriptionResponse, a, b) + } +} + +/** + * @generated from message bsky.DeleteActorMutelistSubscriptionRequest + */ +export class DeleteActorMutelistSubscriptionRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + /** + * @generated from field: string subject_uri = 2; + */ + subjectUri = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.DeleteActorMutelistSubscriptionRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'subject_uri', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): DeleteActorMutelistSubscriptionRequest { + return new DeleteActorMutelistSubscriptionRequest().fromBinary( + bytes, + options, + ) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): DeleteActorMutelistSubscriptionRequest { + return new DeleteActorMutelistSubscriptionRequest().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): DeleteActorMutelistSubscriptionRequest { + return new DeleteActorMutelistSubscriptionRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | DeleteActorMutelistSubscriptionRequest + | PlainMessage + | undefined, + b: + | DeleteActorMutelistSubscriptionRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(DeleteActorMutelistSubscriptionRequest, a, b) + } +} + +/** + * @generated from message bsky.DeleteActorMutelistSubscriptionResponse + */ +export class DeleteActorMutelistSubscriptionResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.DeleteActorMutelistSubscriptionResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): DeleteActorMutelistSubscriptionResponse { + return new DeleteActorMutelistSubscriptionResponse().fromBinary( + bytes, + options, + ) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): DeleteActorMutelistSubscriptionResponse { + return new DeleteActorMutelistSubscriptionResponse().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): DeleteActorMutelistSubscriptionResponse { + return new DeleteActorMutelistSubscriptionResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | DeleteActorMutelistSubscriptionResponse + | PlainMessage + | undefined, + b: + | DeleteActorMutelistSubscriptionResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(DeleteActorMutelistSubscriptionResponse, a, b) + } +} + +/** + * @generated from message bsky.ClearActorMutelistSubscriptionsRequest + */ +export class ClearActorMutelistSubscriptionsRequest extends Message { + /** + * @generated from field: string actor_did = 1; + */ + actorDid = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.ClearActorMutelistSubscriptionsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): ClearActorMutelistSubscriptionsRequest { + return new ClearActorMutelistSubscriptionsRequest().fromBinary( + bytes, + options, + ) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): ClearActorMutelistSubscriptionsRequest { + return new ClearActorMutelistSubscriptionsRequest().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): ClearActorMutelistSubscriptionsRequest { + return new ClearActorMutelistSubscriptionsRequest().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | ClearActorMutelistSubscriptionsRequest + | PlainMessage + | undefined, + b: + | ClearActorMutelistSubscriptionsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(ClearActorMutelistSubscriptionsRequest, a, b) + } +} + +/** + * @generated from message bsky.ClearActorMutelistSubscriptionsResponse + */ +export class ClearActorMutelistSubscriptionsResponse extends Message { + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.ClearActorMutelistSubscriptionsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): ClearActorMutelistSubscriptionsResponse { + return new ClearActorMutelistSubscriptionsResponse().fromBinary( + bytes, + options, + ) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): ClearActorMutelistSubscriptionsResponse { + return new ClearActorMutelistSubscriptionsResponse().fromJson( + jsonValue, + options, + ) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): ClearActorMutelistSubscriptionsResponse { + return new ClearActorMutelistSubscriptionsResponse().fromJsonString( + jsonString, + options, + ) + } + + static equals( + a: + | ClearActorMutelistSubscriptionsResponse + | PlainMessage + | undefined, + b: + | ClearActorMutelistSubscriptionsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(ClearActorMutelistSubscriptionsResponse, a, b) + } +} diff --git a/packages/bsky/src/services/actor/index.ts b/packages/bsky/src/services/actor/index.ts deleted file mode 100644 index 096bf18be9b..00000000000 --- a/packages/bsky/src/services/actor/index.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { sql } from 'kysely' -import { wait } from '@atproto/common' -import { Database } from '../../db' -import { notSoftDeletedClause } from '../../db/util' -import { ActorViews } from './views' -import { ImageUriBuilder } from '../../image/uri' -import { Actor } from '../../db/tables/actor' -import { TimeCidKeyset, paginate } from '../../db/pagination' -import { SearchKeyset, getUserSearchQuery } from '../util/search' -import { FromDb } from '../types' -import { GraphService } from '../graph' -import { LabelService } from '../label' -import { AtUri } from '@atproto/syntax' -import { ids } from '../../lexicon/lexicons' -import { Platform } from '../../notifications' - -export * from './types' - -export class ActorService { - views: ActorViews - - constructor( - public db: Database, - public imgUriBuilder: ImageUriBuilder, - graph: FromDb, - label: FromDb, - ) { - this.views = new ActorViews(this.db, this.imgUriBuilder, graph, label) - } - - static creator( - imgUriBuilder: ImageUriBuilder, - graph: FromDb, - label: FromDb, - ) { - return (db: Database) => new ActorService(db, imgUriBuilder, graph, label) - } - - async getActorDid(handleOrDid: string): Promise { - if (handleOrDid.startsWith('did:')) { - return handleOrDid - } - const subject = await this.getActor(handleOrDid, true) - return subject?.did ?? null - } - - async getActor( - handleOrDid: string, - includeSoftDeleted = false, - ): Promise { - const actors = await this.getActors([handleOrDid], includeSoftDeleted) - return actors[0] || null - } - - async getActors( - handleOrDids: string[], - includeSoftDeleted = false, - ): Promise { - const { ref } = this.db.db.dynamic - const dids: string[] = [] - const handles: string[] = [] - const order: Record = {} - handleOrDids.forEach((item, i) => { - if (item.startsWith('did:')) { - order[item] = i - dids.push(item) - } else { - order[item.toLowerCase()] = i - handles.push(item.toLowerCase()) - } - }) - const results = await this.db.db - .selectFrom('actor') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('actor'))), - ) - .where((qb) => { - if (dids.length) { - qb = qb.orWhere('actor.did', 'in', dids) - } - if (handles.length) { - qb = qb.orWhere( - 'actor.handle', - 'in', - handles.length === 1 - ? [handles[0], handles[0]] // a silly (but worthwhile) optimization to avoid usage of actor_handle_tgrm_idx - : handles, - ) - } - return qb - }) - .selectAll() - .execute() - - return results.sort((a, b) => { - const orderA = order[a.did] ?? order[a.handle?.toLowerCase() ?? ''] - const orderB = order[b.did] ?? order[b.handle?.toLowerCase() ?? ''] - return orderA - orderB - }) - } - - async getProfileRecords(dids: string[], includeSoftDeleted = false) { - if (dids.length === 0) return new Map() - const profileUris = dids.map((did) => - AtUri.make(did, ids.AppBskyActorProfile, 'self').toString(), - ) - const { ref } = this.db.db.dynamic - const res = await this.db.db - .selectFrom('record') - .innerJoin('actor', 'actor.did', 'record.did') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('actor'))), - ) - .where('uri', 'in', profileUris) - .select(['record.did', 'record.json']) - .execute() - return res.reduce((acc, cur) => { - return acc.set(cur.did, JSON.parse(cur.json)) - }, new Map()) - } - - async getSearchResults({ - cursor, - limit = 25, - query = '', - includeSoftDeleted, - }: { - cursor?: string - limit?: number - query?: string - includeSoftDeleted?: boolean - }) { - const searchField = query.startsWith('did:') ? 'did' : 'handle' - let paginatedBuilder - const { ref } = this.db.db.dynamic - const paginationOptions = { - limit, - cursor, - direction: 'asc' as const, - } - let keyset - - if (query && searchField === 'handle') { - keyset = new SearchKeyset(sql``, sql``) - paginatedBuilder = getUserSearchQuery(this.db, { - query, - includeSoftDeleted, - ...paginationOptions, - }).select('distance') - } else { - paginatedBuilder = this.db.db - .selectFrom('actor') - .select([sql`0`.as('distance')]) - keyset = new ListKeyset(ref('indexedAt'), ref('did')) - - // When searchField === 'did', the query will always be a valid string because - // searchField is set to 'did' after checking that the query is a valid did - if (query && searchField === 'did') { - paginatedBuilder = paginatedBuilder.where('actor.did', '=', query) - } - paginatedBuilder = paginate(paginatedBuilder, { - keyset, - ...paginationOptions, - }) - } - - const results: Actor[] = await paginatedBuilder.selectAll('actor').execute() - return { results, cursor: keyset.packFromResult(results) } - } - - async getRepoRev(did: string | null): Promise { - if (did === null) return null - const res = await this.db.db - .selectFrom('actor_sync') - .select('repoRev') - .where('did', '=', did) - .executeTakeFirst() - return res?.repoRev ?? null - } - - async *all( - opts: { - batchSize?: number - forever?: boolean - cooldownMs?: number - startFromDid?: string - } = {}, - ) { - const { - cooldownMs = 1000, - batchSize = 1000, - forever = false, - startFromDid, - } = opts - const baseQuery = this.db.db - .selectFrom('actor') - .selectAll() - .orderBy('did') - .limit(batchSize) - while (true) { - let cursor = startFromDid - do { - const actors = cursor - ? await baseQuery.where('did', '>', cursor).execute() - : await baseQuery.execute() - for (const actor of actors) { - yield actor - } - cursor = actors.at(-1)?.did - } while (cursor) - if (forever) { - await wait(cooldownMs) - } else { - return - } - } - } - - async registerPushDeviceToken( - did: string, - token: string, - platform: Platform, - appId: string, - ) { - await this.db - .asPrimary() - .db.insertInto('notification_push_token') - .values({ did, token, platform, appId }) - .onConflict((oc) => oc.doNothing()) - .execute() - } -} - -type ActorResult = Actor -export class ListKeyset extends TimeCidKeyset<{ - indexedAt: string - did: string // handles are treated identically to cids in TimeCidKeyset -}> { - labelResult(result: { indexedAt: string; did: string }) { - return { primary: result.indexedAt, secondary: result.did } - } -} diff --git a/packages/bsky/src/services/actor/types.ts b/packages/bsky/src/services/actor/types.ts deleted file mode 100644 index d622e641099..00000000000 --- a/packages/bsky/src/services/actor/types.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ListViewBasic } from '../../lexicon/types/app/bsky/graph/defs' -import { Label } from '../../lexicon/types/com/atproto/label/defs' -import { BlockAndMuteState } from '../graph' -import { ListInfoMap } from '../graph/types' -import { Labels } from '../label' - -export type ActorInfo = { - did: string - handle: string - displayName?: string - description?: string // omitted from basic profile view - avatar?: string - indexedAt?: string // omitted from basic profile view - viewer?: { - muted?: boolean - mutedByList?: ListViewBasic - blockedBy?: boolean - blocking?: string - blockingByList?: ListViewBasic - following?: string - followedBy?: string - } - labels?: Label[] -} -export type ActorInfoMap = { [did: string]: ActorInfo } - -export type ProfileViewMap = ActorInfoMap - -export type ProfileInfo = { - did: string - handle: string | null - profileUri: string | null - profileCid: string | null - displayName: string | null - description: string | null - avatarCid: string | null - indexedAt: string | null - profileJson: string | null - viewerFollowing: string | null - viewerFollowedBy: string | null -} - -export type ProfileInfoMap = { [did: string]: ProfileInfo } - -export type ProfileHydrationState = { - profiles: ProfileInfoMap - labels: Labels - lists: ListInfoMap - bam: BlockAndMuteState -} - -export type ProfileDetailInfo = ProfileInfo & { - bannerCid: string | null - followsCount: number | null - followersCount: number | null - postsCount: number | null -} - -export type ProfileDetailInfoMap = { [did: string]: ProfileDetailInfo } - -export type ProfileDetailHydrationState = { - profilesDetailed: ProfileDetailInfoMap - labels: Labels - lists: ListInfoMap - bam: BlockAndMuteState -} - -export const toMapByDid = ( - items: T[], -): Record => { - return items.reduce((cur, item) => { - cur[item.did] = item - return cur - }, {} as Record) -} diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts deleted file mode 100644 index 32e267a8868..00000000000 --- a/packages/bsky/src/services/actor/views.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { mapDefined } from '@atproto/common' -import { INVALID_HANDLE } from '@atproto/syntax' -import { jsonStringToLex } from '@atproto/lexicon' -import { - ProfileViewDetailed, - ProfileView, -} from '../../lexicon/types/app/bsky/actor/defs' -import { Database } from '../../db' -import { noMatch, notSoftDeletedClause } from '../../db/util' -import { Actor } from '../../db/tables/actor' -import { ImageUriBuilder } from '../../image/uri' -import { LabelService, Labels, getSelfLabels } from '../label' -import { BlockAndMuteState, GraphService } from '../graph' -import { - ActorInfoMap, - ProfileDetailHydrationState, - ProfileHydrationState, - ProfileInfoMap, - ProfileViewMap, - toMapByDid, -} from './types' -import { ListInfoMap } from '../graph/types' -import { FromDb } from '../types' - -export class ActorViews { - services: { - label: LabelService - graph: GraphService - } - - constructor( - private db: Database, - private imgUriBuilder: ImageUriBuilder, - private graph: FromDb, - private label: FromDb, - ) { - this.services = { - label: label(db), - graph: graph(db), - } - } - - async profiles( - results: (ActorResult | string)[], // @TODO simplify down to just string[] - viewer: string | null, - opts?: { includeSoftDeleted?: boolean }, - ): Promise { - if (results.length === 0) return {} - const dids = results.map((res) => (typeof res === 'string' ? res : res.did)) - const hydrated = await this.profileHydration(dids, { - viewer, - ...opts, - }) - return this.profilePresentation(dids, hydrated, viewer) - } - - async profilesBasic( - results: (ActorResult | string)[], - viewer: string | null, - opts?: { includeSoftDeleted?: boolean }, - ): Promise { - if (results.length === 0) return {} - const dids = results.map((res) => (typeof res === 'string' ? res : res.did)) - const hydrated = await this.profileHydration(dids, { - viewer, - includeSoftDeleted: opts?.includeSoftDeleted, - }) - return this.profileBasicPresentation(dids, hydrated, viewer) - } - - async profilesList( - results: ActorResult[], - viewer: string | null, - opts?: { includeSoftDeleted?: boolean }, - ): Promise { - const profiles = await this.profiles(results, viewer, opts) - return mapDefined(results, (result) => profiles[result.did]) - } - - async profileDetailHydration( - dids: string[], - opts: { - viewer?: string | null - includeSoftDeleted?: boolean - }, - state?: { - bam: BlockAndMuteState - labels: Labels - }, - ): Promise { - const { viewer = null, includeSoftDeleted } = opts - const { ref } = this.db.db.dynamic - const profileInfosQb = this.db.db - .selectFrom('actor') - .where('actor.did', 'in', dids.length ? dids : ['']) - .leftJoin('profile', 'profile.creator', 'actor.did') - .leftJoin('profile_agg', 'profile_agg.did', 'actor.did') - .leftJoin('record', 'record.uri', 'profile.uri') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('actor'))), - ) - .select([ - 'actor.did as did', - 'actor.handle as handle', - 'profile.uri as profileUri', - 'profile.cid as profileCid', - 'profile.displayName as displayName', - 'profile.description as description', - 'profile.avatarCid as avatarCid', - 'profile.bannerCid as bannerCid', - 'profile.indexedAt as indexedAt', - 'profile_agg.followsCount as followsCount', - 'profile_agg.followersCount as followersCount', - 'profile_agg.postsCount as postsCount', - 'record.json as profileJson', - this.db.db - .selectFrom('follow') - .if(!viewer, (q) => q.where(noMatch)) - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')) - .select('uri') - .as('viewerFollowing'), - this.db.db - .selectFrom('follow') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('creator', '=', ref('actor.did')) - .where('subjectDid', '=', viewer ?? '') - .select('uri') - .as('viewerFollowedBy'), - ]) - const [profiles, labels, bam] = await Promise.all([ - profileInfosQb.execute(), - this.services.label.getLabelsForSubjects(dids, state?.labels), - this.services.graph.getBlockAndMuteState( - viewer ? dids.map((did) => [viewer, did]) : [], - state?.bam, - ), - ]) - const listUris = mapDefined(profiles, ({ did }) => { - const muteList = viewer && bam.muteList([viewer, did]) - const blockList = viewer && bam.blockList([viewer, did]) - const lists: string[] = [] - if (muteList) lists.push(muteList) - if (blockList) lists.push(blockList) - return lists - }).flat() - const lists = await this.services.graph.getListViews(listUris, viewer) - return { profilesDetailed: toMapByDid(profiles), labels, bam, lists } - } - - profileDetailPresentation( - dids: string[], - state: ProfileDetailHydrationState, - opts: { - viewer?: string | null - }, - ): Record { - const { viewer } = opts - const { profilesDetailed, lists, labels, bam } = state - return dids.reduce((acc, did) => { - const prof = profilesDetailed[did] - if (!prof) return acc - const avatar = prof?.avatarCid - ? this.imgUriBuilder.getPresetUri('avatar', prof.did, prof.avatarCid) - : undefined - const banner = prof?.bannerCid - ? this.imgUriBuilder.getPresetUri('banner', prof.did, prof.bannerCid) - : undefined - const mutedByListUri = viewer && bam.muteList([viewer, did]) - const mutedByList = - mutedByListUri && lists[mutedByListUri] - ? this.services.graph.formatListViewBasic(lists[mutedByListUri]) - : undefined - const blockingByListUri = viewer && bam.blockList([viewer, did]) - const blockingByList = - blockingByListUri && lists[blockingByListUri] - ? this.services.graph.formatListViewBasic(lists[blockingByListUri]) - : undefined - const actorLabels = labels[did] ?? [] - const selfLabels = getSelfLabels({ - uri: prof.profileUri, - cid: prof.profileCid, - record: - prof.profileJson !== null - ? (jsonStringToLex(prof.profileJson) as Record) - : null, - }) - acc[did] = { - did: prof.did, - handle: prof.handle ?? INVALID_HANDLE, - displayName: prof?.displayName || undefined, - description: prof?.description || undefined, - avatar, - banner, - followsCount: prof?.followsCount ?? 0, - followersCount: prof?.followersCount ?? 0, - postsCount: prof?.postsCount ?? 0, - indexedAt: prof?.indexedAt || undefined, - viewer: viewer - ? { - muted: bam.mute([viewer, did]), - mutedByList, - blockedBy: !!bam.blockedBy([viewer, did]), - blocking: bam.blocking([viewer, did]) ?? undefined, - blockingByList, - following: - prof?.viewerFollowing && !bam.block([viewer, did]) - ? prof.viewerFollowing - : undefined, - followedBy: - prof?.viewerFollowedBy && !bam.block([viewer, did]) - ? prof.viewerFollowedBy - : undefined, - } - : undefined, - labels: [...actorLabels, ...selfLabels], - } - return acc - }, {} as Record) - } - - async profileHydration( - dids: string[], - opts: { - viewer?: string | null - includeSoftDeleted?: boolean - }, - state?: { - bam: BlockAndMuteState - labels: Labels - }, - ): Promise { - const { viewer = null, includeSoftDeleted } = opts - const { ref } = this.db.db.dynamic - const profileInfosQb = this.db.db - .selectFrom('actor') - .where('actor.did', 'in', dids.length ? dids : ['']) - .leftJoin('profile', 'profile.creator', 'actor.did') - .leftJoin('record', 'record.uri', 'profile.uri') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('actor'))), - ) - .select([ - 'actor.did as did', - 'actor.handle as handle', - 'profile.uri as profileUri', - 'profile.cid as profileCid', - 'profile.displayName as displayName', - 'profile.description as description', - 'profile.avatarCid as avatarCid', - 'profile.indexedAt as indexedAt', - 'record.json as profileJson', - this.db.db - .selectFrom('follow') - .if(!viewer, (q) => q.where(noMatch)) - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')) - .select('uri') - .as('viewerFollowing'), - this.db.db - .selectFrom('follow') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('creator', '=', ref('actor.did')) - .where('subjectDid', '=', viewer ?? '') - .select('uri') - .as('viewerFollowedBy'), - ]) - const [profiles, labels, bam] = await Promise.all([ - profileInfosQb.execute(), - this.services.label.getLabelsForSubjects(dids, state?.labels), - this.services.graph.getBlockAndMuteState( - viewer ? dids.map((did) => [viewer, did]) : [], - state?.bam, - ), - ]) - const listUris = mapDefined(profiles, ({ did }) => { - const muteList = viewer && bam.muteList([viewer, did]) - const blockList = viewer && bam.blockList([viewer, did]) - const lists: string[] = [] - if (muteList) lists.push(muteList) - if (blockList) lists.push(blockList) - return lists - }).flat() - const lists = await this.services.graph.getListViews(listUris, viewer) - return { profiles: toMapByDid(profiles), labels, bam, lists } - } - - profilePresentation( - dids: string[], - state: { - profiles: ProfileInfoMap - lists: ListInfoMap - labels: Labels - bam: BlockAndMuteState - }, - viewer: string | null, - ): ProfileViewMap { - const { profiles, lists, labels, bam } = state - return dids.reduce((acc, did) => { - const prof = profiles[did] - if (!prof) return acc - const avatar = prof?.avatarCid - ? this.imgUriBuilder.getPresetUri('avatar', prof.did, prof.avatarCid) - : undefined - const mutedByListUri = viewer && bam.muteList([viewer, did]) - const mutedByList = - mutedByListUri && lists[mutedByListUri] - ? this.services.graph.formatListViewBasic(lists[mutedByListUri]) - : undefined - const blockingByListUri = viewer && bam.blockList([viewer, did]) - const blockingByList = - blockingByListUri && lists[blockingByListUri] - ? this.services.graph.formatListViewBasic(lists[blockingByListUri]) - : undefined - const actorLabels = labels[did] ?? [] - const selfLabels = getSelfLabels({ - uri: prof.profileUri, - cid: prof.profileCid, - record: - prof.profileJson !== null - ? (jsonStringToLex(prof.profileJson) as Record) - : null, - }) - acc[did] = { - did: prof.did, - handle: prof.handle ?? INVALID_HANDLE, - displayName: prof?.displayName || undefined, - description: prof?.description || undefined, - avatar, - indexedAt: prof?.indexedAt || undefined, - viewer: viewer - ? { - muted: bam.mute([viewer, did]), - mutedByList, - blockedBy: !!bam.blockedBy([viewer, did]), - blocking: bam.blocking([viewer, did]) ?? undefined, - blockingByList, - following: - prof?.viewerFollowing && !bam.block([viewer, did]) - ? prof.viewerFollowing - : undefined, - followedBy: - prof?.viewerFollowedBy && !bam.block([viewer, did]) - ? prof.viewerFollowedBy - : undefined, - } - : undefined, - labels: [...actorLabels, ...selfLabels], - } - return acc - }, {} as ProfileViewMap) - } - - profileBasicPresentation( - dids: string[], - state: ProfileHydrationState, - viewer: string | null, - ): ProfileViewMap { - const result = this.profilePresentation(dids, state, viewer) - return Object.values(result).reduce((acc, prof) => { - const profileBasic = { - did: prof.did, - handle: prof.handle, - displayName: prof.displayName, - avatar: prof.avatar, - viewer: prof.viewer, - labels: prof.labels, - } - acc[prof.did] = profileBasic - return acc - }, {} as ProfileViewMap) - } -} - -type ActorResult = Actor diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts deleted file mode 100644 index 9b94fd6c714..00000000000 --- a/packages/bsky/src/services/feed/index.ts +++ /dev/null @@ -1,567 +0,0 @@ -import { sql } from 'kysely' -import { AtUri } from '@atproto/syntax' -import { jsonStringToLex } from '@atproto/lexicon' -import { mapDefined } from '@atproto/common' -import { Database } from '../../db' -import { countAll, noMatch, notSoftDeletedClause } from '../../db/util' -import { ImageUriBuilder } from '../../image/uri' -import { ids } from '../../lexicon/lexicons' -import { - Record as PostRecord, - isRecord as isPostRecord, -} from '../../lexicon/types/app/bsky/feed/post' -import { - Record as ThreadgateRecord, - isListRule, -} from '../../lexicon/types/app/bsky/feed/threadgate' -import { isMain as isEmbedImages } from '../../lexicon/types/app/bsky/embed/images' -import { isMain as isEmbedExternal } from '../../lexicon/types/app/bsky/embed/external' -import { - isMain as isEmbedRecord, - isViewRecord, -} from '../../lexicon/types/app/bsky/embed/record' -import { isMain as isEmbedRecordWithMedia } from '../../lexicon/types/app/bsky/embed/recordWithMedia' -import { - PostInfoMap, - FeedItemType, - FeedRow, - FeedGenInfoMap, - PostEmbedViews, - RecordEmbedViewRecordMap, - PostInfo, - RecordEmbedViewRecord, - PostBlocksMap, - FeedHydrationState, - ThreadgateInfoMap, -} from './types' -import { LabelService } from '../label' -import { ActorService } from '../actor' -import { - BlockAndMuteState, - GraphService, - ListInfoMap, - RelationshipPair, -} from '../graph' -import { FeedViews } from './views' -import { threadgateToPostUri, postToThreadgateUri } from './util' -import { FromDb } from '../types' - -export * from './types' - -export class FeedService { - views: FeedViews - services: { - label: LabelService - actor: ActorService - graph: GraphService - } - - constructor( - public db: Database, - public imgUriBuilder: ImageUriBuilder, - private actor: FromDb, - private label: FromDb, - private graph: FromDb, - ) { - this.views = new FeedViews(this.db, this.imgUriBuilder, actor, graph) - this.services = { - label: label(this.db), - actor: actor(this.db), - graph: graph(this.db), - } - } - - static creator( - imgUriBuilder: ImageUriBuilder, - actor: FromDb, - label: FromDb, - graph: FromDb, - ) { - return (db: Database) => - new FeedService(db, imgUriBuilder, actor, label, graph) - } - - selectPostQb() { - return this.db.db - .selectFrom('post') - .select([ - sql`${'post'}`.as('type'), - 'post.uri as uri', - 'post.cid as cid', - 'post.uri as postUri', - 'post.creator as originatorDid', - 'post.creator as postAuthorDid', - 'post.replyParent as replyParent', - 'post.replyRoot as replyRoot', - 'post.sortAt as sortAt', - ]) - } - - selectFeedItemQb() { - return this.db.db - .selectFrom('feed_item') - .innerJoin('post', 'post.uri', 'feed_item.postUri') - .selectAll('feed_item') - .select([ - 'post.replyRoot', - 'post.replyParent', - 'post.creator as postAuthorDid', - ]) - } - - selectFeedGeneratorQb(viewer?: string | null) { - const { ref } = this.db.db.dynamic - return this.db.db - .selectFrom('feed_generator') - .innerJoin('actor', 'actor.did', 'feed_generator.creator') - .innerJoin('record', 'record.uri', 'feed_generator.uri') - .selectAll('feed_generator') - .where(notSoftDeletedClause(ref('actor'))) - .where(notSoftDeletedClause(ref('record'))) - .select((qb) => - qb - .selectFrom('like') - .whereRef('like.subject', '=', 'feed_generator.uri') - .select(countAll.as('count')) - .as('likeCount'), - ) - .select((qb) => - qb - .selectFrom('like') - .if(!viewer, (q) => q.where(noMatch)) - .where('like.creator', '=', viewer ?? '') - .whereRef('like.subject', '=', 'feed_generator.uri') - .select('uri') - .as('viewerLike'), - ) - } - - async getPostInfos( - postUris: string[], - viewer: string | null, - includeSoftDeleted?: boolean, - ): Promise { - if (postUris.length < 1) return {} - const db = this.db.db - const { ref } = db.dynamic - const posts = await db - .selectFrom('post') - .where('post.uri', 'in', postUris) - .innerJoin('actor', 'actor.did', 'post.creator') - .innerJoin('record', 'record.uri', 'post.uri') - .leftJoin('post_agg', 'post_agg.uri', 'post.uri') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('actor'))), - ) // Ensures post reply parent/roots get omitted from views when taken down - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('record'))), - ) - .select([ - 'post.uri as uri', - 'post.cid as cid', - 'post.creator as creator', - 'post.sortAt as indexedAt', - 'post.invalidReplyRoot as invalidReplyRoot', - 'post.violatesThreadGate as violatesThreadGate', - 'record.json as recordJson', - 'post_agg.likeCount as likeCount', - 'post_agg.repostCount as repostCount', - 'post_agg.replyCount as replyCount', - 'post.tags as tags', - db - .selectFrom('repost') - .if(!viewer, (q) => q.where(noMatch)) - .where('creator', '=', viewer ?? '') - .whereRef('subject', '=', ref('post.uri')) - .select('uri') - .as('requesterRepost'), - db - .selectFrom('like') - .if(!viewer, (q) => q.where(noMatch)) - .where('creator', '=', viewer ?? '') - .whereRef('subject', '=', ref('post.uri')) - .select('uri') - .as('requesterLike'), - ]) - .execute() - return posts.reduce((acc, cur) => { - const { recordJson, ...post } = cur - const record = jsonStringToLex(recordJson) as PostRecord - const info: PostInfo = { - ...post, - invalidReplyRoot: post.invalidReplyRoot ?? false, - violatesThreadGate: post.violatesThreadGate ?? false, - record, - viewer, - } - return Object.assign(acc, { [post.uri]: info }) - }, {} as PostInfoMap) - } - - async getFeedGeneratorInfos(generatorUris: string[], viewer: string | null) { - if (generatorUris.length < 1) return {} - const feedGens = await this.selectFeedGeneratorQb(viewer) - .where('feed_generator.uri', 'in', generatorUris) - .execute() - return feedGens.reduce( - (acc, cur) => ({ - ...acc, - [cur.uri]: { - ...cur, - viewer: viewer ? { like: cur.viewerLike } : undefined, - }, - }), - {} as FeedGenInfoMap, - ) - } - - async getFeedItems(uris: string[]): Promise> { - if (uris.length < 1) return {} - const feedItems = await this.selectFeedItemQb() - .where('feed_item.uri', 'in', uris) - .execute() - return feedItems.reduce((acc, item) => { - return Object.assign(acc, { [item.uri]: item }) - }, {} as Record) - } - - async postUrisToFeedItems(uris: string[]): Promise { - const feedItems = await this.getFeedItems(uris) - return mapDefined(uris, (uri) => feedItems[uri]) - } - - feedItemRefs(items: FeedRow[]) { - const actorDids = new Set() - const postUris = new Set() - for (const item of items) { - postUris.add(item.postUri) - actorDids.add(item.postAuthorDid) - actorDids.add(item.originatorDid) - if (item.replyParent) { - postUris.add(item.replyParent) - actorDids.add(new AtUri(item.replyParent).hostname) - } - if (item.replyRoot) { - postUris.add(item.replyRoot) - actorDids.add(new AtUri(item.replyRoot).hostname) - } - } - return { dids: actorDids, uris: postUris } - } - - async feedHydration( - refs: { - dids: Set - uris: Set - viewer: string | null - includeSoftDeleted?: boolean - }, - depth = 0, - ): Promise { - const { viewer, dids, uris } = refs - const [posts, threadgates, labels, bam] = await Promise.all([ - this.getPostInfos(Array.from(uris), viewer, refs.includeSoftDeleted), - this.threadgatesByPostUri(Array.from(uris)), - this.services.label.getLabelsForSubjects([...uris, ...dids]), - this.services.graph.getBlockAndMuteState( - viewer ? [...dids].map((did) => [viewer, did]) : [], - ), - ]) - - // profileState for labels and bam handled above, profileHydration() shouldn't fetch additional - const [profileState, blocks, lists] = await Promise.all([ - this.services.actor.views.profileHydration( - Array.from(dids), - { viewer, includeSoftDeleted: refs.includeSoftDeleted }, - { bam, labels }, - ), - this.blocksForPosts(posts, bam), - this.listsForThreadgates(threadgates, viewer), - ]) - const embeds = await this.embedsForPosts(posts, blocks, viewer, depth) - return { - posts, - threadgates, - blocks, - embeds, - labels, // includes info for profiles - bam, // includes info for profiles - profiles: profileState.profiles, - lists: Object.assign(lists, profileState.lists), - } - } - - // applies blocks for visibility to third-parties (i.e. based on post content) - async blocksForPosts( - posts: PostInfoMap, - bam?: BlockAndMuteState, - ): Promise { - const relationships: RelationshipPair[] = [] - const byPost: Record = {} - const didFromUri = (uri) => new AtUri(uri).host - for (const post of Object.values(posts)) { - // skip posts that we can't process or appear to already have been processed - if (!isPostRecord(post.record)) continue - if (byPost[post.uri]) continue - byPost[post.uri] = {} - // 3p block for replies - const parentUri = post.record.reply?.parent.uri - const parentDid = parentUri ? didFromUri(parentUri) : null - // 3p block for record embeds - const embedUris = nestedRecordUris([post.record]) - // gather actor relationships among posts - if (parentDid) { - const pair: RelationshipPair = [post.creator, parentDid] - relationships.push(pair) - byPost[post.uri].reply = pair - } - for (const embedUri of embedUris) { - const pair: RelationshipPair = [post.creator, didFromUri(embedUri)] - relationships.push(pair) - byPost[post.uri].embed = pair - } - } - // compute block state from all actor relationships among posts - const blockState = await this.services.graph.getBlockState( - relationships, - bam, - ) - const result: PostBlocksMap = {} - Object.entries(byPost).forEach(([uri, block]) => { - if (block.embed && blockState.block(block.embed)) { - result[uri] ??= {} - result[uri].embed = true - } - if (block.reply && blockState.block(block.reply)) { - result[uri] ??= {} - result[uri].reply = true - } - }) - return result - } - - async embedsForPosts( - postInfos: PostInfoMap, - blocks: PostBlocksMap, - viewer: string | null, - depth: number, - ) { - const postMap = postRecordsFromInfos(postInfos) - const posts = Object.values(postMap) - if (posts.length < 1) { - return {} - } - const recordEmbedViews = - depth > 1 ? {} : await this.nestedRecordViews(posts, viewer, depth) - - const postEmbedViews: PostEmbedViews = {} - for (const [uri, post] of Object.entries(postMap)) { - const creator = new AtUri(uri).hostname - if (!post.embed) continue - if (isEmbedImages(post.embed)) { - postEmbedViews[uri] = this.views.imagesEmbedView(creator, post.embed) - } else if (isEmbedExternal(post.embed)) { - postEmbedViews[uri] = this.views.externalEmbedView(creator, post.embed) - } else if (isEmbedRecord(post.embed)) { - if (!recordEmbedViews[post.embed.record.uri]) continue - postEmbedViews[uri] = { - $type: 'app.bsky.embed.record#view', - record: applyEmbedBlock( - uri, - blocks, - recordEmbedViews[post.embed.record.uri], - ), - } - } else if (isEmbedRecordWithMedia(post.embed)) { - const embedRecordView = recordEmbedViews[post.embed.record.record.uri] - if (!embedRecordView) continue - const formatted = this.views.getRecordWithMediaEmbedView( - creator, - post.embed, - applyEmbedBlock(uri, blocks, embedRecordView), - ) - if (formatted) { - postEmbedViews[uri] = formatted - } - } - } - return postEmbedViews - } - - async nestedRecordViews( - posts: PostRecord[], - viewer: string | null, - depth: number, - ): Promise { - const nestedUris = nestedRecordUris(posts) - if (nestedUris.length < 1) return {} - const nestedDids = new Set() - const nestedPostUris = new Set() - const nestedFeedGenUris = new Set() - const nestedListUris = new Set() - for (const uri of nestedUris) { - const parsed = new AtUri(uri) - nestedDids.add(parsed.hostname) - if (parsed.collection === ids.AppBskyFeedPost) { - nestedPostUris.add(uri) - } else if (parsed.collection === ids.AppBskyFeedGenerator) { - nestedFeedGenUris.add(uri) - } else if (parsed.collection === ids.AppBskyGraphList) { - nestedListUris.add(uri) - } - } - const [feedState, feedGenInfos, listViews] = await Promise.all([ - this.feedHydration( - { - dids: nestedDids, - uris: nestedPostUris, - viewer, - }, - depth + 1, - ), - this.getFeedGeneratorInfos([...nestedFeedGenUris], viewer), - this.services.graph.getListViews([...nestedListUris], viewer), - ]) - const actorInfos = this.services.actor.views.profileBasicPresentation( - [...nestedDids], - feedState, - viewer, - ) - const recordEmbedViews: RecordEmbedViewRecordMap = {} - for (const uri of nestedUris) { - const collection = new AtUri(uri).collection - if (collection === ids.AppBskyFeedGenerator && feedGenInfos[uri]) { - const genView = this.views.formatFeedGeneratorView( - feedGenInfos[uri], - actorInfos, - ) - if (genView) { - recordEmbedViews[uri] = { - $type: 'app.bsky.feed.defs#generatorView', - ...genView, - } - } - } else if (collection === ids.AppBskyGraphList && listViews[uri]) { - const listView = this.services.graph.formatListView( - listViews[uri], - actorInfos, - ) - if (listView) { - recordEmbedViews[uri] = { - $type: 'app.bsky.graph.defs#listView', - ...listView, - } - } - } else if (collection === ids.AppBskyFeedPost && feedState.posts[uri]) { - const formatted = this.views.formatPostView( - uri, - actorInfos, - feedState.posts, - feedState.threadgates, - feedState.embeds, - feedState.labels, - feedState.lists, - viewer, - ) - recordEmbedViews[uri] = this.views.getRecordEmbedView( - uri, - formatted, - depth > 0, - ) - } else { - recordEmbedViews[uri] = { - $type: 'app.bsky.embed.record#viewNotFound', - uri, - notFound: true, - } - } - } - return recordEmbedViews - } - - async threadgatesByPostUri(postUris: string[]): Promise { - const gates = postUris.length - ? await this.db.db - .selectFrom('record') - .where('uri', 'in', postUris.map(postToThreadgateUri)) - .select(['uri', 'cid', 'json']) - .execute() - : [] - const gatesByPostUri = gates.reduce((acc, gate) => { - const record = jsonStringToLex(gate.json) as ThreadgateRecord - const postUri = threadgateToPostUri(gate.uri) - if (record.post !== postUri) return acc // invalid, skip - acc[postUri] = { uri: gate.uri, cid: gate.cid, record } - return acc - }, {} as ThreadgateInfoMap) - return gatesByPostUri - } - - listsForThreadgates( - threadgates: ThreadgateInfoMap, - viewer: string | null, - ): Promise { - const listsUris = new Set() - Object.values(threadgates).forEach((gate) => { - gate?.record.allow?.forEach((rule) => { - if (isListRule(rule)) { - listsUris.add(rule.list) - } - }) - }) - return this.services.graph.getListViews([...listsUris], viewer) - } -} - -const postRecordsFromInfos = ( - infos: PostInfoMap, -): { [uri: string]: PostRecord } => { - const records: { [uri: string]: PostRecord } = {} - for (const [uri, info] of Object.entries(infos)) { - if (isPostRecord(info.record)) { - records[uri] = info.record - } - } - return records -} - -const nestedRecordUris = (posts: PostRecord[]): string[] => { - const uris: string[] = [] - for (const post of posts) { - if (!post.embed) continue - if (isEmbedRecord(post.embed)) { - uris.push(post.embed.record.uri) - } else if (isEmbedRecordWithMedia(post.embed)) { - uris.push(post.embed.record.record.uri) - } else { - continue - } - } - return uris -} - -type PostRelationships = { reply?: RelationshipPair; embed?: RelationshipPair } - -function applyEmbedBlock( - uri: string, - blocks: PostBlocksMap, - view: RecordEmbedViewRecord, -): RecordEmbedViewRecord { - if (isViewRecord(view) && blocks[uri]?.embed) { - return { - $type: 'app.bsky.embed.record#viewBlocked', - uri: view.uri, - blocked: true, - author: { - did: view.author.did, - viewer: view.author.viewer - ? { - blockedBy: view.author.viewer?.blockedBy, - blocking: view.author.viewer?.blocking, - } - : undefined, - }, - } - } - return view -} diff --git a/packages/bsky/src/services/feed/types.ts b/packages/bsky/src/services/feed/types.ts deleted file mode 100644 index 8d4bd67f6bb..00000000000 --- a/packages/bsky/src/services/feed/types.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Selectable } from 'kysely' -import { Record as ThreadgateRecord } from '../../lexicon/types/app/bsky/feed/threadgate' -import { View as ImagesEmbedView } from '../../lexicon/types/app/bsky/embed/images' -import { View as ExternalEmbedView } from '../../lexicon/types/app/bsky/embed/external' -import { - ViewBlocked, - ViewNotFound, - ViewRecord, - View as RecordEmbedView, -} from '../../lexicon/types/app/bsky/embed/record' -import { View as RecordWithMediaEmbedView } from '../../lexicon/types/app/bsky/embed/recordWithMedia' -import { - BlockedPost, - GeneratorView, - NotFoundPost, - PostView, -} from '../../lexicon/types/app/bsky/feed/defs' -import { FeedGenerator } from '../../db/tables/feed-generator' -import { ListView } from '../../lexicon/types/app/bsky/graph/defs' -import { ProfileHydrationState } from '../actor' -import { Labels } from '../label' -import { BlockAndMuteState } from '../graph' - -export type PostEmbedViews = { - [uri: string]: PostEmbedView -} - -export type PostEmbedView = - | ImagesEmbedView - | ExternalEmbedView - | RecordEmbedView - | RecordWithMediaEmbedView - -export type PostInfo = { - uri: string - cid: string - creator: string - record: Record - indexedAt: string - likeCount: number | null - repostCount: number | null - replyCount: number | null - requesterRepost: string | null - requesterLike: string | null - invalidReplyRoot: boolean - violatesThreadGate: boolean - viewer: string | null -} - -export type PostInfoMap = { [uri: string]: PostInfo } - -export type PostBlocksMap = { - [uri: string]: { reply?: boolean; embed?: boolean } -} - -export type ThreadgateInfo = { - uri: string - cid: string - record: ThreadgateRecord -} - -export type ThreadgateInfoMap = { - [postUri: string]: ThreadgateInfo -} - -export type FeedGenInfo = Selectable & { - likeCount: number - viewer?: { - like?: string - } -} - -export type FeedGenInfoMap = { [uri: string]: FeedGenInfo } - -export type FeedItemType = 'post' | 'repost' - -export type FeedRow = { - type: FeedItemType - uri: string - cid: string - postUri: string - postAuthorDid: string - originatorDid: string - replyParent: string | null - replyRoot: string | null - sortAt: string -} - -export type MaybePostView = PostView | NotFoundPost | BlockedPost - -export type RecordEmbedViewRecord = - | ViewRecord - | ViewNotFound - | ViewBlocked - | GeneratorView - | ListView - -export type RecordEmbedViewRecordMap = { [uri: string]: RecordEmbedViewRecord } - -export type FeedHydrationState = ProfileHydrationState & { - posts: PostInfoMap - threadgates: ThreadgateInfoMap - embeds: PostEmbedViews - labels: Labels - blocks: PostBlocksMap - bam: BlockAndMuteState -} diff --git a/packages/bsky/src/services/feed/views.ts b/packages/bsky/src/services/feed/views.ts deleted file mode 100644 index ac733032acd..00000000000 --- a/packages/bsky/src/services/feed/views.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { mapDefined } from '@atproto/common' -import { AtUri } from '@atproto/syntax' -import { Database } from '../../db' -import { - FeedViewPost, - GeneratorView, - PostView, -} from '../../lexicon/types/app/bsky/feed/defs' -import { - Main as EmbedImages, - isMain as isEmbedImages, - View as EmbedImagesView, -} from '../../lexicon/types/app/bsky/embed/images' -import { - Main as EmbedExternal, - isMain as isEmbedExternal, - View as EmbedExternalView, -} from '../../lexicon/types/app/bsky/embed/external' -import { Main as EmbedRecordWithMedia } from '../../lexicon/types/app/bsky/embed/recordWithMedia' -import { - ViewBlocked, - ViewNotFound, - ViewRecord, -} from '../../lexicon/types/app/bsky/embed/record' -import { Record as PostRecord } from '../../lexicon/types/app/bsky/feed/post' -import { isListRule } from '../../lexicon/types/app/bsky/feed/threadgate' -import { - PostEmbedViews, - FeedGenInfo, - FeedRow, - MaybePostView, - PostInfoMap, - RecordEmbedViewRecord, - PostBlocksMap, - FeedHydrationState, - ThreadgateInfoMap, - ThreadgateInfo, -} from './types' -import { Labels, getSelfLabels } from '../label' -import { ImageUriBuilder } from '../../image/uri' -import { ActorInfoMap, ActorService } from '../actor' -import { ListInfoMap, GraphService } from '../graph' -import { FromDb } from '../types' -import { parseThreadGate } from './util' - -export class FeedViews { - services: { - actor: ActorService - graph: GraphService - } - - constructor( - public db: Database, - public imgUriBuilder: ImageUriBuilder, - private actor: FromDb, - private graph: FromDb, - ) { - this.services = { - actor: actor(this.db), - graph: graph(this.db), - } - } - - static creator( - imgUriBuilder: ImageUriBuilder, - actor: FromDb, - graph: FromDb, - ) { - return (db: Database) => new FeedViews(db, imgUriBuilder, actor, graph) - } - - formatFeedGeneratorView( - info: FeedGenInfo, - profiles: ActorInfoMap, - ): GeneratorView | undefined { - const profile = profiles[info.creator] - if (!profile) { - return undefined - } - return { - uri: info.uri, - cid: info.cid, - did: info.feedDid, - creator: profile, - displayName: info.displayName ?? undefined, - description: info.description ?? undefined, - descriptionFacets: info.descriptionFacets - ? JSON.parse(info.descriptionFacets) - : undefined, - avatar: info.avatarCid - ? this.imgUriBuilder.getPresetUri( - 'avatar', - info.creator, - info.avatarCid, - ) - : undefined, - likeCount: info.likeCount, - viewer: info.viewer - ? { - like: info.viewer.like ?? undefined, - } - : undefined, - indexedAt: info.indexedAt, - } - } - - formatFeed( - items: FeedRow[], - state: FeedHydrationState, - viewer: string | null, - opts?: { - usePostViewUnion?: boolean - }, - ): FeedViewPost[] { - const { posts, threadgates, profiles, blocks, embeds, labels, lists } = - state - const actors = this.services.actor.views.profileBasicPresentation( - Object.keys(profiles), - state, - viewer, - ) - const feed: FeedViewPost[] = [] - for (const item of items) { - const info = posts[item.postUri] - const post = this.formatPostView( - item.postUri, - actors, - posts, - threadgates, - embeds, - labels, - lists, - viewer, - ) - // skip over not found post - if (!post) { - continue - } - const feedPost = { post } - if (item.type === 'repost') { - const originator = actors[item.originatorDid] - // skip over reposts where we don't have reposter profile - if (!originator) { - continue - } else { - feedPost['reason'] = { - $type: 'app.bsky.feed.defs#reasonRepost', - by: originator, - indexedAt: item.sortAt, - } - } - } - // posts that violate reply-gating may appear in feeds, but without any thread context - if ( - item.replyParent && - item.replyRoot && - !info?.invalidReplyRoot && - !info?.violatesThreadGate - ) { - const replyParent = this.formatMaybePostView( - item.replyParent, - item.uri, - actors, - posts, - threadgates, - embeds, - labels, - lists, - blocks, - viewer, - opts, - ) - const replyRoot = this.formatMaybePostView( - item.replyRoot, - item.uri, - actors, - posts, - threadgates, - embeds, - labels, - lists, - blocks, - viewer, - opts, - ) - if (replyRoot && replyParent) { - feedPost['reply'] = { - root: replyRoot, - parent: replyParent, - } - } - } - feed.push(feedPost) - } - return feed - } - - formatPostView( - uri: string, - actors: ActorInfoMap, - posts: PostInfoMap, - threadgates: ThreadgateInfoMap, - embeds: PostEmbedViews, - labels: Labels, - lists: ListInfoMap, - viewer: string | null, - ): PostView | undefined { - const post = posts[uri] - const gate = threadgates[uri] - const author = actors[post?.creator] - if (!post || !author) { - return undefined - } - const postLabels = labels[uri] ?? [] - const postSelfLabels = getSelfLabels({ - uri: post.uri, - cid: post.cid, - record: post.record, - }) - return { - uri: post.uri, - cid: post.cid, - author: author, - record: post.record, - embed: embeds[uri], - replyCount: post.replyCount ?? 0, - repostCount: post.repostCount ?? 0, - likeCount: post.likeCount ?? 0, - indexedAt: post.indexedAt, - viewer: post.viewer - ? { - repost: post.requesterRepost ?? undefined, - like: post.requesterLike ?? undefined, - replyDisabled: this.userReplyDisabled( - uri, - actors, - posts, - threadgates, - lists, - viewer, - ), - } - : undefined, - labels: [...postLabels, ...postSelfLabels], - threadgate: - !post.record.reply && gate - ? this.formatThreadgate(gate, lists) - : undefined, - } - } - - userReplyDisabled( - uri: string, - actors: ActorInfoMap, - posts: PostInfoMap, - threadgates: ThreadgateInfoMap, - lists: ListInfoMap, - viewer: string | null, - ): boolean | undefined { - if (viewer === null) { - return undefined - } else if (posts[uri]?.violatesThreadGate) { - return true - } - - const rootUriStr: string = - posts[uri]?.record?.['reply']?.['root']?.['uri'] ?? uri - const gate = threadgates[rootUriStr]?.record - if (!gate) { - return undefined - } - const rootPost = posts[rootUriStr]?.record as PostRecord | undefined - const ownerDid = new AtUri(rootUriStr).hostname - - const { - canReply, - allowFollowing, - allowListUris = [], - } = parseThreadGate(viewer, ownerDid, rootPost ?? null, gate ?? null) - - if (canReply) { - return false - } - if (allowFollowing && actors[ownerDid]?.viewer?.followedBy) { - return false - } - for (const listUri of allowListUris) { - const list = lists[listUri] - if (list?.viewerInList) { - return false - } - } - return true - } - - formatMaybePostView( - uri: string, - replyUri: string | null, - actors: ActorInfoMap, - posts: PostInfoMap, - threadgates: ThreadgateInfoMap, - embeds: PostEmbedViews, - labels: Labels, - lists: ListInfoMap, - blocks: PostBlocksMap, - viewer: string | null, - opts?: { - usePostViewUnion?: boolean - }, - ): MaybePostView | undefined { - const post = this.formatPostView( - uri, - actors, - posts, - threadgates, - embeds, - labels, - lists, - viewer, - ) - if (!post) { - if (!opts?.usePostViewUnion) return - return this.notFoundPost(uri) - } - if ( - post.author.viewer?.blockedBy || - post.author.viewer?.blocking || - (replyUri !== null && blocks[replyUri]?.reply) - ) { - if (!opts?.usePostViewUnion) return - return this.blockedPost(post) - } - return { - $type: 'app.bsky.feed.defs#postView', - ...post, - } - } - - blockedPost(post: PostView) { - return { - $type: 'app.bsky.feed.defs#blockedPost', - uri: post.uri, - blocked: true as const, - author: { - did: post.author.did, - viewer: post.author.viewer - ? { - blockedBy: post.author.viewer?.blockedBy, - blocking: post.author.viewer?.blocking, - } - : undefined, - }, - } - } - - notFoundPost(uri: string) { - return { - $type: 'app.bsky.feed.defs#notFoundPost', - uri: uri, - notFound: true as const, - } - } - - imagesEmbedView(did: string, embed: EmbedImages) { - const imgViews = embed.images.map((img) => ({ - thumb: this.imgUriBuilder.getPresetUri( - 'feed_thumbnail', - did, - img.image.ref, - ), - fullsize: this.imgUriBuilder.getPresetUri( - 'feed_fullsize', - did, - img.image.ref, - ), - alt: img.alt, - aspectRatio: img.aspectRatio, - })) - return { - $type: 'app.bsky.embed.images#view', - images: imgViews, - } - } - - externalEmbedView(did: string, embed: EmbedExternal) { - const { uri, title, description, thumb } = embed.external - return { - $type: 'app.bsky.embed.external#view', - external: { - uri, - title, - description, - thumb: thumb - ? this.imgUriBuilder.getPresetUri('feed_thumbnail', did, thumb.ref) - : undefined, - }, - } - } - - getRecordEmbedView( - uri: string, - post?: PostView, - omitEmbeds = false, - ): (ViewRecord | ViewNotFound | ViewBlocked) & { $type: string } { - if (!post) { - return { - $type: 'app.bsky.embed.record#viewNotFound', - uri, - notFound: true, - } - } - if (post.author.viewer?.blocking || post.author.viewer?.blockedBy) { - return { - $type: 'app.bsky.embed.record#viewBlocked', - uri, - blocked: true, - author: { - did: post.author.did, - viewer: post.author.viewer - ? { - blockedBy: post.author.viewer?.blockedBy, - blocking: post.author.viewer?.blocking, - } - : undefined, - }, - } - } - return { - $type: 'app.bsky.embed.record#viewRecord', - uri: post.uri, - cid: post.cid, - author: post.author, - value: post.record, - labels: post.labels, - indexedAt: post.indexedAt, - embeds: omitEmbeds ? undefined : post.embed ? [post.embed] : [], - } - } - - getRecordWithMediaEmbedView( - did: string, - embed: EmbedRecordWithMedia, - embedRecordView: RecordEmbedViewRecord, - ) { - let mediaEmbed: EmbedImagesView | EmbedExternalView - if (isEmbedImages(embed.media)) { - mediaEmbed = this.imagesEmbedView(did, embed.media) - } else if (isEmbedExternal(embed.media)) { - mediaEmbed = this.externalEmbedView(did, embed.media) - } else { - return - } - return { - $type: 'app.bsky.embed.recordWithMedia#view', - record: { - record: embedRecordView, - }, - media: mediaEmbed, - } - } - - formatThreadgate(gate: ThreadgateInfo, lists: ListInfoMap) { - return { - uri: gate.uri, - cid: gate.cid, - record: gate.record, - lists: mapDefined(gate.record.allow ?? [], (rule) => { - if (!isListRule(rule)) return - const list = lists[rule.list] - if (!list) return - return this.services.graph.formatListViewBasic(list) - }), - } - } -} diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts deleted file mode 100644 index bc6d3d05677..00000000000 --- a/packages/bsky/src/services/graph/index.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { sql } from 'kysely' -import { Database } from '../../db' -import { ImageUriBuilder } from '../../image/uri' -import { DbRef, Subquery, valuesList } from '../../db/util' -import { ListInfo } from './types' -import { ActorInfoMap } from '../actor' -import { - ListView, - ListViewBasic, -} from '../../lexicon/types/app/bsky/graph/defs' - -export * from './types' - -export class GraphService { - constructor(public db: Database, public imgUriBuilder: ImageUriBuilder) {} - - static creator(imgUriBuilder: ImageUriBuilder) { - return (db: Database) => new GraphService(db, imgUriBuilder) - } - - async muteActor(info: { - subjectDid: string - mutedByDid: string - createdAt?: Date - }) { - const { subjectDid, mutedByDid, createdAt = new Date() } = info - await this.db - .asPrimary() - .db.insertInto('mute') - .values({ - subjectDid, - mutedByDid, - createdAt: createdAt.toISOString(), - }) - .onConflict((oc) => oc.doNothing()) - .execute() - } - - async unmuteActor(info: { subjectDid: string; mutedByDid: string }) { - const { subjectDid, mutedByDid } = info - await this.db - .asPrimary() - .db.deleteFrom('mute') - .where('subjectDid', '=', subjectDid) - .where('mutedByDid', '=', mutedByDid) - .execute() - } - - async muteActorList(info: { - list: string - mutedByDid: string - createdAt?: Date - }) { - const { list, mutedByDid, createdAt = new Date() } = info - await this.db - .asPrimary() - .db.insertInto('list_mute') - .values({ - listUri: list, - mutedByDid, - createdAt: createdAt.toISOString(), - }) - .onConflict((oc) => oc.doNothing()) - .execute() - } - - async unmuteActorList(info: { list: string; mutedByDid: string }) { - const { list, mutedByDid } = info - await this.db - .asPrimary() - .db.deleteFrom('list_mute') - .where('listUri', '=', list) - .where('mutedByDid', '=', mutedByDid) - .execute() - } - - getListsQb(viewer: string | null) { - const { ref } = this.db.db.dynamic - return this.db.db - .selectFrom('list') - .innerJoin('actor', 'actor.did', 'list.creator') - .selectAll('list') - .selectAll('actor') - .select('list.sortAt as sortAt') - .select([ - this.db.db - .selectFrom('list_mute') - .where('list_mute.mutedByDid', '=', viewer ?? '') - .whereRef('list_mute.listUri', '=', ref('list.uri')) - .select('list_mute.listUri') - .as('viewerMuted'), - this.db.db - .selectFrom('list_block') - .where('list_block.creator', '=', viewer ?? '') - .whereRef('list_block.subjectUri', '=', ref('list.uri')) - .select('list_block.uri') - .as('viewerListBlockUri'), - this.db.db - .selectFrom('list_item') - .whereRef('list_item.listUri', '=', ref('list.uri')) - .where('list_item.subjectDid', '=', viewer ?? '') - .select('list_item.uri') - .as('viewerInList'), - ]) - } - - getListItemsQb() { - return this.db.db - .selectFrom('list_item') - .innerJoin('actor as subject', 'subject.did', 'list_item.subjectDid') - .selectAll('subject') - .select([ - 'list_item.uri as uri', - 'list_item.cid as cid', - 'list_item.sortAt as sortAt', - ]) - } - - async getBlockAndMuteState( - pairs: RelationshipPair[], - bam?: BlockAndMuteState, - ) { - pairs = bam ? pairs.filter((pair) => !bam.has(pair)) : pairs - const result = bam ?? new BlockAndMuteState() - if (!pairs.length) return result - const { ref } = this.db.db.dynamic - const sourceRef = ref('pair.source') - const targetRef = ref('pair.target') - const values = valuesList(pairs.map((p) => sql`${p[0]}, ${p[1]}`)) - const items = await this.db.db - .selectFrom(values.as(sql`pair (source, target)`)) - .select([ - sql`${sourceRef}`.as('source'), - sql`${targetRef}`.as('target'), - this.db.db - .selectFrom('actor_block') - .whereRef('creator', '=', sourceRef) - .whereRef('subjectDid', '=', targetRef) - .select('uri') - .as('blocking'), - this.db.db - .selectFrom('list_item') - .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') - .whereRef('list_block.creator', '=', sourceRef) - .whereRef('list_item.subjectDid', '=', targetRef) - .whereExists((qb) => - this.modListSubquery(qb, ref('list_item.listUri')), - ) - .select('list_item.listUri') - .limit(1) - .as('blockingViaList'), - this.db.db - .selectFrom('actor_block') - .whereRef('creator', '=', targetRef) - .whereRef('subjectDid', '=', sourceRef) - .select('uri') - .as('blockedBy'), - this.db.db - .selectFrom('list_item') - .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') - .whereRef('list_block.creator', '=', targetRef) - .whereRef('list_item.subjectDid', '=', sourceRef) - .whereExists((qb) => - this.modListSubquery(qb, ref('list_item.listUri')), - ) - .select('list_item.listUri') - .limit(1) - .as('blockedByViaList'), - this.db.db - .selectFrom('mute') - .whereRef('mutedByDid', '=', sourceRef) - .whereRef('subjectDid', '=', targetRef) - .select(sql`${true}`.as('val')) - .as('muting'), - this.db.db - .selectFrom('list_item') - .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') - .whereRef('list_mute.mutedByDid', '=', sourceRef) - .whereRef('list_item.subjectDid', '=', targetRef) - .whereExists((qb) => - this.modListSubquery(qb, ref('list_item.listUri')), - ) - .select('list_item.listUri') - .limit(1) - .as('mutingViaList'), - ]) - .selectAll() - .execute() - items.forEach((item) => result.add(item)) - return result - } - - modListSubquery(qb: Subquery, ref: DbRef) { - return qb - .selectFrom('list') - .select('uri') - .where('list.purpose', '=', 'app.bsky.graph.defs#modlist') - .whereRef('list.uri', '=', ref) - } - - async getBlockState(pairs: RelationshipPair[], bam?: BlockAndMuteState) { - pairs = bam ? pairs.filter((pair) => !bam.has(pair)) : pairs - const result = bam ?? new BlockAndMuteState() - if (!pairs.length) return result - const { ref } = this.db.db.dynamic - const sourceRef = ref('pair.source') - const targetRef = ref('pair.target') - const values = valuesList(pairs.map((p) => sql`${p[0]}, ${p[1]}`)) - const items = await this.db.db - .selectFrom(values.as(sql`pair (source, target)`)) - .select([ - sql`${sourceRef}`.as('source'), - sql`${targetRef}`.as('target'), - this.db.db - .selectFrom('actor_block') - .whereRef('creator', '=', sourceRef) - .whereRef('subjectDid', '=', targetRef) - .select('uri') - .as('blocking'), - this.db.db - .selectFrom('list_item') - .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') - .whereRef('list_block.creator', '=', sourceRef) - .whereRef('list_item.subjectDid', '=', targetRef) - .whereExists((qb) => - this.modListSubquery(qb, ref('list_item.listUri')), - ) - .select('list_item.listUri') - .limit(1) - .as('blockingViaList'), - this.db.db - .selectFrom('actor_block') - .whereRef('creator', '=', targetRef) - .whereRef('subjectDid', '=', sourceRef) - .select('uri') - .as('blockedBy'), - this.db.db - .selectFrom('list_item') - .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') - .whereRef('list_block.creator', '=', targetRef) - .whereRef('list_item.subjectDid', '=', sourceRef) - .whereExists((qb) => - this.modListSubquery(qb, ref('list_item.listUri')), - ) - .select('list_item.listUri') - .limit(1) - .as('blockedByViaList'), - ]) - .selectAll() - .execute() - items.forEach((item) => result.add(item)) - return result - } - - async getListViews(listUris: string[], requester: string | null) { - if (listUris.length < 1) return {} - const lists = await this.getListsQb(requester) - .where('list.uri', 'in', listUris) - .execute() - return lists.reduce( - (acc, cur) => ({ - ...acc, - [cur.uri]: cur, - }), - {}, - ) - } - - formatListView(list: ListInfo, profiles: ActorInfoMap): ListView | undefined { - if (!profiles[list.creator]) { - return undefined - } - return { - ...this.formatListViewBasic(list), - creator: profiles[list.creator], - description: list.description ?? undefined, - descriptionFacets: list.descriptionFacets - ? JSON.parse(list.descriptionFacets) - : undefined, - indexedAt: list.sortAt, - } - } - - formatListViewBasic(list: ListInfo): ListViewBasic { - return { - uri: list.uri, - cid: list.cid, - name: list.name, - purpose: list.purpose, - avatar: list.avatarCid - ? this.imgUriBuilder.getPresetUri( - 'avatar', - list.creator, - list.avatarCid, - ) - : undefined, - indexedAt: list.sortAt, - viewer: { - muted: !!list.viewerMuted, - blocked: list.viewerListBlockUri ?? undefined, - }, - } - } -} - -export type RelationshipPair = [didA: string, didB: string] - -export class BlockAndMuteState { - hasIdx = new Map>() // did -> did - blockIdx = new Map>() // did -> did -> block uri - blockListIdx = new Map>() // did -> did -> list uri - muteIdx = new Map>() // did -> did - muteListIdx = new Map>() // did -> did -> list uri - constructor(items: BlockAndMuteInfo[] = []) { - items.forEach((item) => this.add(item)) - } - add(item: BlockAndMuteInfo) { - if (item.source === item.target) { - return // we do not respect self-blocks or self-mutes - } - if (item.blocking) { - const map = this.blockIdx.get(item.source) ?? new Map() - map.set(item.target, item.blocking) - if (!this.blockIdx.has(item.source)) { - this.blockIdx.set(item.source, map) - } - } - if (item.blockingViaList) { - const map = this.blockListIdx.get(item.source) ?? new Map() - map.set(item.target, item.blockingViaList) - if (!this.blockListIdx.has(item.source)) { - this.blockListIdx.set(item.source, map) - } - } - if (item.blockedBy) { - const map = this.blockIdx.get(item.target) ?? new Map() - map.set(item.source, item.blockedBy) - if (!this.blockIdx.has(item.target)) { - this.blockIdx.set(item.target, map) - } - } - if (item.blockedByViaList) { - const map = this.blockListIdx.get(item.target) ?? new Map() - map.set(item.source, item.blockedByViaList) - if (!this.blockListIdx.has(item.target)) { - this.blockListIdx.set(item.target, map) - } - } - if (item.muting) { - const set = this.muteIdx.get(item.source) ?? new Set() - set.add(item.target) - if (!this.muteIdx.has(item.source)) { - this.muteIdx.set(item.source, set) - } - } - if (item.mutingViaList) { - const map = this.muteListIdx.get(item.source) ?? new Map() - map.set(item.target, item.mutingViaList) - if (!this.muteListIdx.has(item.source)) { - this.muteListIdx.set(item.source, map) - } - } - const set = this.hasIdx.get(item.source) ?? new Set() - set.add(item.target) - if (!this.hasIdx.has(item.source)) { - this.hasIdx.set(item.source, set) - } - } - block(pair: RelationshipPair): boolean { - return !!this.blocking(pair) || !!this.blockedBy(pair) - } - // block or list uri - blocking(pair: RelationshipPair): string | null { - return this.blockIdx.get(pair[0])?.get(pair[1]) ?? this.blockList(pair) - } - // block or list uri - blockedBy(pair: RelationshipPair): string | null { - return this.blocking([pair[1], pair[0]]) - } - mute(pair: RelationshipPair): boolean { - return !!this.muteIdx.get(pair[0])?.has(pair[1]) || !!this.muteList(pair) - } - // list uri - blockList(pair: RelationshipPair): string | null { - return this.blockListIdx.get(pair[0])?.get(pair[1]) ?? null - } - muteList(pair: RelationshipPair): string | null { - return this.muteListIdx.get(pair[0])?.get(pair[1]) ?? null - } - has(pair: RelationshipPair) { - return !!this.hasIdx.get(pair[0])?.has(pair[1]) - } -} - -type BlockAndMuteInfo = { - source: string - target: string - blocking?: string | null - blockingViaList?: string | null - blockedBy?: string | null - blockedByViaList?: string | null - muting?: true | null - mutingViaList?: string | null -} diff --git a/packages/bsky/src/services/graph/types.ts b/packages/bsky/src/services/graph/types.ts deleted file mode 100644 index 5ff254dc383..00000000000 --- a/packages/bsky/src/services/graph/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Selectable } from 'kysely' -import { List } from '../../db/tables/list' - -export type ListInfo = Selectable & { - viewerMuted: string | null - viewerListBlockUri: string | null - viewerInList: string | null -} - -export type ListInfoMap = Record diff --git a/packages/bsky/src/services/index.ts b/packages/bsky/src/services/index.ts deleted file mode 100644 index 2e5b4725681..00000000000 --- a/packages/bsky/src/services/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ImageUriBuilder } from '../image/uri' -import { ActorService } from './actor' -import { FeedService } from './feed' -import { GraphService } from './graph' -import { ModerationService } from './moderation' -import { LabelCacheOpts, LabelService } from './label' -import { ImageInvalidator } from '../image/invalidator' -import { FromDb, FromDbPrimary } from './types' - -export function createServices(resources: { - imgUriBuilder: ImageUriBuilder - imgInvalidator: ImageInvalidator - labelCacheOpts: LabelCacheOpts -}): Services { - const { imgUriBuilder, imgInvalidator, labelCacheOpts } = resources - const label = LabelService.creator(labelCacheOpts) - const graph = GraphService.creator(imgUriBuilder) - const actor = ActorService.creator(imgUriBuilder, graph, label) - const moderation = ModerationService.creator(imgUriBuilder, imgInvalidator) - const feed = FeedService.creator(imgUriBuilder, actor, label, graph) - return { - actor, - feed, - moderation, - graph, - label, - } -} - -export type Services = { - actor: FromDb - feed: FromDb - graph: FromDb - moderation: FromDbPrimary - label: FromDb -} diff --git a/packages/bsky/src/services/label/index.ts b/packages/bsky/src/services/label/index.ts deleted file mode 100644 index f4c11295da7..00000000000 --- a/packages/bsky/src/services/label/index.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { sql } from 'kysely' -import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import { Database } from '../../db' -import { Label, isSelfLabels } from '../../lexicon/types/com/atproto/label/defs' -import { ids } from '../../lexicon/lexicons' -import { ReadThroughCache } from '../../cache/read-through' -import { Redis } from '../../redis' - -export type Labels = Record - -export type LabelCacheOpts = { - redis: Redis - staleTTL: number - maxTTL: number -} - -export class LabelService { - public cache: ReadThroughCache | null - - constructor(public db: Database, cacheOpts: LabelCacheOpts | null) { - if (cacheOpts) { - this.cache = new ReadThroughCache(cacheOpts.redis, { - ...cacheOpts, - fetchMethod: async (subject: string) => { - const res = await fetchLabelsForSubjects(db, [subject]) - return res[subject] ?? [] - }, - fetchManyMethod: (subjects: string[]) => - fetchLabelsForSubjects(db, subjects), - }) - } - } - - static creator(cacheOpts: LabelCacheOpts | null) { - return (db: Database) => new LabelService(db, cacheOpts) - } - - async formatAndCreate( - src: string, - uri: string, - cid: string | null, - labels: { create?: string[]; negate?: string[] }, - ): Promise { - const { create = [], negate = [] } = labels - const toCreate = create.map((val) => ({ - src, - uri, - cid: cid ?? undefined, - val, - neg: false, - cts: new Date().toISOString(), - })) - const toNegate = negate.map((val) => ({ - src, - uri, - cid: cid ?? undefined, - val, - neg: true, - cts: new Date().toISOString(), - })) - const formatted = [...toCreate, ...toNegate] - await this.createLabels(formatted) - return formatted - } - - async createLabels(labels: Label[]) { - if (labels.length < 1) return - const dbVals = labels.map((l) => ({ - ...l, - cid: l.cid ?? '', - neg: !!l.neg, - })) - const { ref } = this.db.db.dynamic - const excluded = (col: string) => ref(`excluded.${col}`) - await this.db - .asPrimary() - .db.insertInto('label') - .values(dbVals) - .onConflict((oc) => - oc.columns(['src', 'uri', 'cid', 'val']).doUpdateSet({ - neg: sql`${excluded('neg')}`, - cts: sql`${excluded('cts')}`, - }), - ) - .execute() - } - - async getLabelsForUris( - subjects: string[], - opts?: { - includeNeg?: boolean - skipCache?: boolean - }, - ): Promise { - if (subjects.length < 1) return {} - const res = this.cache - ? await this.cache.getMany(subjects, { revalidate: opts?.skipCache }) - : await fetchLabelsForSubjects(this.db, subjects) - - if (opts?.includeNeg) { - return res - } - - const noNegs: Labels = {} - for (const [key, val] of Object.entries(res)) { - noNegs[key] = val.filter((label) => !label.neg) - } - return noNegs - } - - // gets labels for any record. when did is present, combine labels for both did & profile record. - async getLabelsForSubjects( - subjects: string[], - opts?: { - includeNeg?: boolean - skipCache?: boolean - }, - labels: Labels = {}, - ): Promise { - if (subjects.length < 1) return labels - const expandedSubjects = subjects.flatMap((subject) => { - if (labels[subject]) return [] // skip over labels we already have fetched - if (subject.startsWith('did:')) { - return [ - subject, - AtUri.make(subject, ids.AppBskyActorProfile, 'self').toString(), - ] - } - return subject - }) - const labelsByUri = await this.getLabelsForUris(expandedSubjects, opts) - return Object.keys(labelsByUri).reduce((acc, cur) => { - const uri = cur.startsWith('at://') ? new AtUri(cur) : null - if ( - uri && - uri.collection === ids.AppBskyActorProfile && - uri.rkey === 'self' - ) { - // combine labels for profile + did - const did = uri.hostname - acc[did] ??= [] - acc[did].push(...labelsByUri[cur]) - } - acc[cur] ??= [] - acc[cur].push(...labelsByUri[cur]) - return acc - }, labels) - } - - async getLabels( - subject: string, - opts?: { - includeNeg?: boolean - skipCache?: boolean - }, - ): Promise { - const labels = await this.getLabelsForUris([subject], opts) - return labels[subject] ?? [] - } - - async getLabelsForProfile( - did: string, - opts?: { - includeNeg?: boolean - skipCache?: boolean - }, - ): Promise { - const labels = await this.getLabelsForSubjects([did], opts) - return labels[did] ?? [] - } -} - -export function getSelfLabels(details: { - uri: string | null - cid: string | null - record: Record | null -}): Label[] { - const { uri, cid, record } = details - if (!uri || !cid || !record) return [] - if (!isSelfLabels(record.labels)) return [] - const src = new AtUri(uri).host // record creator - const cts = - typeof record.createdAt === 'string' - ? normalizeDatetimeAlways(record.createdAt) - : new Date(0).toISOString() - return record.labels.values.map(({ val }) => { - return { src, uri, cid, val, cts, neg: false } - }) -} - -const fetchLabelsForSubjects = async ( - db: Database, - subjects: string[], -): Promise> => { - if (subjects.length === 0) { - return {} - } - const res = await db.db - .selectFrom('label') - .where('label.uri', 'in', subjects) - .selectAll() - .execute() - const labelMap = res.reduce((acc, cur) => { - acc[cur.uri] ??= [] - acc[cur.uri].push({ - ...cur, - cid: cur.cid === '' ? undefined : cur.cid, - neg: cur.neg, - }) - return acc - }, {} as Record) - // ensure we cache negatives - for (const subject of subjects) { - labelMap[subject] ??= [] - } - return labelMap -} diff --git a/packages/bsky/src/services/moderation/index.ts b/packages/bsky/src/services/moderation/index.ts deleted file mode 100644 index 71380e16884..00000000000 --- a/packages/bsky/src/services/moderation/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { CID } from 'multiformats/cid' -import { AtUri } from '@atproto/syntax' -import { PrimaryDatabase } from '../../db' -import { ImageUriBuilder } from '../../image/uri' -import { ImageInvalidator } from '../../image/invalidator' -import { StatusAttr } from '../../lexicon/types/com/atproto/admin/defs' - -export class ModerationService { - constructor( - public db: PrimaryDatabase, - public imgUriBuilder: ImageUriBuilder, - public imgInvalidator: ImageInvalidator, - ) {} - - static creator( - imgUriBuilder: ImageUriBuilder, - imgInvalidator: ImageInvalidator, - ) { - return (db: PrimaryDatabase) => - new ModerationService(db, imgUriBuilder, imgInvalidator) - } - - async takedownRepo(info: { takedownRef: string; did: string }) { - const { takedownRef, did } = info - await this.db.db - .updateTable('actor') - .set({ takedownRef }) - .where('did', '=', did) - .where('takedownRef', 'is', null) - .executeTakeFirst() - } - - async reverseTakedownRepo(info: { did: string }) { - await this.db.db - .updateTable('actor') - .set({ takedownRef: null }) - .where('did', '=', info.did) - .execute() - } - - async takedownRecord(info: { takedownRef: string; uri: AtUri; cid: CID }) { - const { takedownRef, uri } = info - await this.db.db - .updateTable('record') - .set({ takedownRef }) - .where('uri', '=', uri.toString()) - .where('takedownRef', 'is', null) - .executeTakeFirst() - } - - async reverseTakedownRecord(info: { uri: AtUri }) { - await this.db.db - .updateTable('record') - .set({ takedownRef: null }) - .where('uri', '=', info.uri.toString()) - .execute() - } - - async takedownBlob(info: { takedownRef: string; did: string; cid: string }) { - const { takedownRef, did, cid } = info - await this.db.db - .insertInto('blob_takedown') - .values({ did, cid, takedownRef }) - .onConflict((oc) => oc.doNothing()) - .execute() - const paths = ImageUriBuilder.presets.map((id) => { - const imgUri = this.imgUriBuilder.getPresetUri(id, did, cid) - return imgUri.replace(this.imgUriBuilder.endpoint, '') - }) - await this.imgInvalidator.invalidate(cid.toString(), paths) - } - - async reverseTakedownBlob(info: { did: string; cid: string }) { - const { did, cid } = info - await this.db.db - .deleteFrom('blob_takedown') - .where('did', '=', did) - .where('cid', '=', cid) - .execute() - } - - async getRepoTakedownRef(did: string): Promise { - const res = await this.db.db - .selectFrom('actor') - .where('did', '=', did) - .selectAll() - .executeTakeFirst() - return res ? formatStatus(res.takedownRef) : null - } - - async getRecordTakedownRef(uri: string): Promise { - const res = await this.db.db - .selectFrom('record') - .where('uri', '=', uri) - .selectAll() - .executeTakeFirst() - return res ? formatStatus(res.takedownRef) : null - } - - async getBlobTakedownRef( - did: string, - cid: string, - ): Promise { - const res = await this.db.db - .selectFrom('blob_takedown') - .where('did', '=', did) - .where('cid', '=', cid) - .selectAll() - .executeTakeFirst() - // this table only tracks takedowns not all blobs - // so if no result is returned then the blob is not taken down (rather than not found) - return formatStatus(res?.takedownRef ?? null) - } -} - -const formatStatus = (ref: string | null): StatusAttr => { - return ref ? { applied: true, ref } : { applied: false } -} diff --git a/packages/bsky/src/services/types.ts b/packages/bsky/src/services/types.ts deleted file mode 100644 index 2039d6c07de..00000000000 --- a/packages/bsky/src/services/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Database, PrimaryDatabase } from '../db' - -export type FromDb = (db: Database) => T -export type FromDbPrimary = (db: PrimaryDatabase) => T diff --git a/packages/bsky/src/services/util/notification.ts b/packages/bsky/src/services/util/notification.ts deleted file mode 100644 index e8eb618cff6..00000000000 --- a/packages/bsky/src/services/util/notification.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { sql } from 'kysely' -import { countAll } from '../../db/util' -import { PrimaryDatabase } from '../../db' - -// i.e. 30 days before the last time the user checked their notifs -export const BEFORE_LAST_SEEN_DAYS = 30 -// i.e. 180 days before the latest unread notification -export const BEFORE_LATEST_UNREAD_DAYS = 180 -// don't consider culling unreads until they hit this threshold, and then enforce beforeLatestUnreadThresholdDays -export const UNREAD_KEPT_COUNT = 500 - -export const tidyNotifications = async (db: PrimaryDatabase, did: string) => { - const stats = await db.db - .selectFrom('notification') - .select([ - sql<0 | 1>`("sortAt" < "lastSeenNotifs")`.as('read'), - countAll.as('count'), - sql`min("sortAt")`.as('earliestAt'), - sql`max("sortAt")`.as('latestAt'), - sql`max("lastSeenNotifs")`.as('lastSeenAt'), - ]) - .leftJoin('actor_state', 'actor_state.did', 'notification.did') - .where('notification.did', '=', did) - .groupBy(sql`1`) // group by read (i.e. 1st column) - .execute() - const readStats = stats.find((stat) => stat.read) - const unreadStats = stats.find((stat) => !stat.read) - let readCutoffAt: Date | undefined - let unreadCutoffAt: Date | undefined - if (readStats) { - readCutoffAt = addDays( - new Date(readStats.lastSeenAt), - -BEFORE_LAST_SEEN_DAYS, - ) - } - if (unreadStats && unreadStats.count > UNREAD_KEPT_COUNT) { - unreadCutoffAt = addDays( - new Date(unreadStats.latestAt), - -BEFORE_LATEST_UNREAD_DAYS, - ) - } - // take most recent of read/unread cutoffs - const cutoffAt = greatest(readCutoffAt, unreadCutoffAt) - if (cutoffAt) { - // skip delete if it won't catch any notifications - const earliestAt = least(readStats?.earliestAt, unreadStats?.earliestAt) - if (earliestAt && earliestAt < cutoffAt.toISOString()) { - await db.db - .deleteFrom('notification') - .where('did', '=', did) - .where('sortAt', '<', cutoffAt.toISOString()) - .execute() - } - } -} - -const addDays = (date: Date, days: number) => { - date.setDate(date.getDate() + days) - return date -} - -const least = (a: T | undefined, b: T | undefined) => { - return a !== undefined && (b === undefined || a < b) ? a : b -} - -const greatest = (a: T | undefined, b: T | undefined) => { - return a !== undefined && (b === undefined || a > b) ? a : b -} - -type Ordered = string | number | Date diff --git a/packages/bsky/src/services/util/post.ts b/packages/bsky/src/services/util/post.ts deleted file mode 100644 index 19e7fa3ee2c..00000000000 --- a/packages/bsky/src/services/util/post.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { sql } from 'kysely' -import DatabaseSchema from '../../db/database-schema' - -export const getDescendentsQb = ( - db: DatabaseSchema, - opts: { - uri: string - depth: number // required, protects against cycles - }, -) => { - const { uri, depth } = opts - const query = db.withRecursive('descendent(uri, depth)', (cte) => { - return cte - .selectFrom('post') - .select(['post.uri as uri', sql`1`.as('depth')]) - .where(sql`1`, '<=', depth) - .where('replyParent', '=', uri) - .unionAll( - cte - .selectFrom('post') - .innerJoin('descendent', 'descendent.uri', 'post.replyParent') - .where('descendent.depth', '<', depth) - .select([ - 'post.uri as uri', - sql`descendent.depth + 1`.as('depth'), - ]), - ) - }) - return query -} - -export const getAncestorsAndSelfQb = ( - db: DatabaseSchema, - opts: { - uri: string - parentHeight: number // required, protects against cycles - }, -) => { - const { uri, parentHeight } = opts - const query = db.withRecursive( - 'ancestor(uri, ancestorUri, height)', - (cte) => { - return cte - .selectFrom('post') - .select([ - 'post.uri as uri', - 'post.replyParent as ancestorUri', - sql`0`.as('height'), - ]) - .where('uri', '=', uri) - .unionAll( - cte - .selectFrom('post') - .innerJoin('ancestor', 'ancestor.ancestorUri', 'post.uri') - .where('ancestor.height', '<', parentHeight) - .select([ - 'post.uri as uri', - 'post.replyParent as ancestorUri', - sql`ancestor.height + 1`.as('height'), - ]), - ) - }, - ) - return query -} diff --git a/packages/bsky/src/services/util/search.ts b/packages/bsky/src/services/util/search.ts deleted file mode 100644 index 994d2f43879..00000000000 --- a/packages/bsky/src/services/util/search.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { sql } from 'kysely' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { Database } from '../../db' -import { notSoftDeletedClause, DbRef, AnyQb } from '../../db/util' -import { GenericKeyset, paginate } from '../../db/pagination' - -export const getUserSearchQuery = ( - db: Database, - opts: { - query: string - limit: number - cursor?: string - includeSoftDeleted?: boolean - }, -) => { - const { ref } = db.db.dynamic - const { query, limit, cursor, includeSoftDeleted } = opts - // Matching user accounts based on handle - const distanceAccount = distance(query, ref('handle')) - let accountsQb = getMatchingAccountsQb(db, { query, includeSoftDeleted }) - accountsQb = paginate(accountsQb, { - limit, - cursor, - direction: 'asc', - keyset: new SearchKeyset(distanceAccount, ref('actor.did')), - }) - // Matching profiles based on display name - const distanceProfile = distance(query, ref('displayName')) - let profilesQb = getMatchingProfilesQb(db, { query, includeSoftDeleted }) - profilesQb = paginate(profilesQb, { - limit, - cursor, - direction: 'asc', - keyset: new SearchKeyset(distanceProfile, ref('actor.did')), - }) - // Combine and paginate result set - return paginate(combineAccountsAndProfilesQb(db, accountsQb, profilesQb), { - limit, - cursor, - direction: 'asc', - keyset: new SearchKeyset(ref('distance'), ref('actor.did')), - }) -} - -// Takes maximal advantage of trigram index at the expense of ability to paginate. -export const getUserSearchQuerySimple = ( - db: Database, - opts: { - query: string - limit: number - }, -) => { - const { ref } = db.db.dynamic - const { query, limit } = opts - // Matching user accounts based on handle - const accountsQb = getMatchingAccountsQb(db, { query }) - .orderBy('distance', 'asc') - .limit(limit) - // Matching profiles based on display name - const profilesQb = getMatchingProfilesQb(db, { query }) - .orderBy('distance', 'asc') - .limit(limit) - // Combine and paginate result set - return paginate(combineAccountsAndProfilesQb(db, accountsQb, profilesQb), { - limit, - direction: 'asc', - keyset: new SearchKeyset(ref('distance'), ref('actor.did')), - }) -} - -// Matching user accounts based on handle -const getMatchingAccountsQb = ( - db: Database, - opts: { query: string; includeSoftDeleted?: boolean }, -) => { - const { ref } = db.db.dynamic - const { query, includeSoftDeleted } = opts - const distanceAccount = distance(query, ref('handle')) - return db.db - .selectFrom('actor') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('actor'))), - ) - .where('actor.handle', 'is not', null) - .where(similar(query, ref('handle'))) // Coarse filter engaging trigram index - .select(['actor.did as did', distanceAccount.as('distance')]) -} - -// Matching profiles based on display name -const getMatchingProfilesQb = ( - db: Database, - opts: { query: string; includeSoftDeleted?: boolean }, -) => { - const { ref } = db.db.dynamic - const { query, includeSoftDeleted } = opts - const distanceProfile = distance(query, ref('displayName')) - return db.db - .selectFrom('profile') - .innerJoin('actor', 'actor.did', 'profile.creator') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('actor'))), - ) - .where('actor.handle', 'is not', null) - .where(similar(query, ref('displayName'))) // Coarse filter engaging trigram index - .select(['profile.creator as did', distanceProfile.as('distance')]) -} - -// Combine profile and account result sets -const combineAccountsAndProfilesQb = ( - db: Database, - accountsQb: AnyQb, - profilesQb: AnyQb, -) => { - // Combine user account and profile results, taking best matches from each - const emptyQb = db.db - .selectFrom('actor') - .where(sql`1 = 0`) - .select([sql.literal('').as('did'), sql`0`.as('distance')]) - const resultsQb = db.db - .selectFrom( - emptyQb - .unionAll(sql`${accountsQb}`) // The sql`` is adding parens - .unionAll(sql`${profilesQb}`) - .as('accounts_and_profiles'), - ) - .selectAll() - .distinctOn('did') // Per did, take whichever of account and profile distance is best - .orderBy('did') - .orderBy('distance') - return db.db - .selectFrom(resultsQb.as('results')) - .innerJoin('actor', 'actor.did', 'results.did') -} - -// Remove leading @ in case a handle is input that way -export const cleanQuery = (query: string) => query.trim().replace(/^@/g, '') - -// Uses pg_trgm strict word similarity to check similarity between a search query and a stored value -const distance = (query: string, ref: DbRef) => - sql`(${query} <<-> ${ref})` - -// Can utilize trigram index to match on strict word similarity. -// The word_similarity_threshold is set to .4 (i.e. distance < .6) in db/index.ts. -const similar = (query: string, ref: DbRef) => - sql`(${query} <% ${ref})` - -type Result = { distance: number; did: string } -type LabeledResult = { primary: number; secondary: string } -export class SearchKeyset extends GenericKeyset { - labelResult(result: Result) { - return { - primary: result.distance, - secondary: result.did, - } - } - labeledResultToCursor(labeled: LabeledResult) { - return { - primary: labeled.primary.toString().replace('0.', '.'), - secondary: labeled.secondary, - } - } - cursorToLabeledResult(cursor: { primary: string; secondary: string }) { - const distance = parseFloat(cursor.primary) - if (isNaN(distance)) { - throw new InvalidRequestError('Malformed cursor') - } - return { - primary: distance, - secondary: cursor.secondary, - } - } -} diff --git a/packages/bsky/src/views/index.ts b/packages/bsky/src/views/index.ts new file mode 100644 index 00000000000..99450f0491c --- /dev/null +++ b/packages/bsky/src/views/index.ts @@ -0,0 +1,854 @@ +import { AtUri, INVALID_HANDLE, normalizeDatetimeAlways } from '@atproto/syntax' +import { mapDefined } from '@atproto/common' +import { ImageUriBuilder } from '../image/uri' +import { HydrationState } from '../hydration/hydrator' +import { ids } from '../lexicon/lexicons' +import { + ProfileViewDetailed, + ProfileView, + ProfileViewBasic, + ViewerState as ProfileViewerState, +} from '../lexicon/types/app/bsky/actor/defs' +import { + BlockedPost, + FeedViewPost, + GeneratorView, + NotFoundPost, + PostView, + ReasonRepost, + ThreadViewPost, + ThreadgateView, +} from '../lexicon/types/app/bsky/feed/defs' +import { ListView, ListViewBasic } from '../lexicon/types/app/bsky/graph/defs' +import { creatorFromUri, parseThreadGate, cidFromBlobJson } from './util' +import { isListRule } from '../lexicon/types/app/bsky/feed/threadgate' +import { isSelfLabels } from '../lexicon/types/com/atproto/label/defs' +import { + Embed, + EmbedBlocked, + EmbedNotFound, + EmbedView, + ExternalEmbed, + ExternalEmbedView, + ImagesEmbed, + ImagesEmbedView, + MaybePostView, + NotificationView, + PostEmbedView, + RecordEmbed, + RecordEmbedView, + RecordEmbedViewInternal, + RecordWithMedia, + RecordWithMediaView, + isExternalEmbed, + isImagesEmbed, + isRecordEmbed, + isRecordWithMedia, +} from './types' +import { Label } from '../hydration/label' +import { FeedItem, Post, Repost } from '../hydration/feed' +import { RecordInfo } from '../hydration/util' +import { Notification } from '../proto/bsky_pb' + +export class Views { + constructor(public imgUriBuilder: ImageUriBuilder) {} + + // Actor + // ------------ + + actorIsTakendown(did: string, state: HydrationState): boolean { + return !!state.actors?.get(did)?.takedownRef + } + + viewerBlockExists(did: string, state: HydrationState): boolean { + const actor = state.profileViewers?.get(did) + if (!actor) return false + return ( + !!actor.blockedBy || + !!actor.blocking || + !!actor.blockedByList || + !!actor.blockingByList + ) + } + + viewerMuteExists(did: string, state: HydrationState): boolean { + const actor = state.profileViewers?.get(did) + if (!actor) return false + return actor.muted || !!actor.mutedByList + } + + profileDetailed( + did: string, + state: HydrationState, + ): ProfileViewDetailed | undefined { + const actor = state.actors?.get(did) + if (!actor) return + const baseView = this.profile(did, state) + if (!baseView) return + const profileAggs = state.profileAggs?.get(did) + return { + ...baseView, + banner: actor.profile?.banner + ? this.imgUriBuilder.getPresetUri( + 'banner', + did, + cidFromBlobJson(actor.profile.banner), + ) + : undefined, + followersCount: profileAggs?.followers, + followsCount: profileAggs?.follows, + postsCount: profileAggs?.posts, + } + } + + profile(did: string, state: HydrationState): ProfileView | undefined { + const actor = state.actors?.get(did) + if (!actor) return + const basicView = this.profileBasic(did, state) + if (!basicView) return + return { + ...basicView, + description: actor.profile?.description || undefined, + indexedAt: actor.sortedAt?.toISOString(), + } + } + + profileBasic( + did: string, + state: HydrationState, + ): ProfileViewBasic | undefined { + const actor = state.actors?.get(did) + if (!actor) return + const profileUri = AtUri.make( + did, + ids.AppBskyActorProfile, + 'self', + ).toString() + const labels = [ + ...(state.labels?.get(did) ?? []), + ...(state.labels?.get(profileUri) ?? []), + ...this.selfLabels({ + uri: profileUri, + cid: actor.profileCid?.toString(), + record: actor.profile, + }), + ] + return { + did, + handle: actor.handle ?? INVALID_HANDLE, + displayName: actor.profile?.displayName, + avatar: actor.profile?.avatar + ? this.imgUriBuilder.getPresetUri( + 'avatar', + did, + cidFromBlobJson(actor.profile.avatar), + ) + : undefined, + viewer: this.profileViewer(did, state), + labels, + } + } + + profileViewer( + did: string, + state: HydrationState, + ): ProfileViewerState | undefined { + const viewer = state.profileViewers?.get(did) + if (!viewer) return + const blockedByUri = viewer.blockedBy || viewer.blockedByList + const blockingUri = viewer.blocking || viewer.blockingByList + const block = !!blockedByUri || !!blockingUri + return { + muted: viewer.muted || !!viewer.mutedByList, + mutedByList: viewer.mutedByList + ? this.listBasic(viewer.mutedByList, state) + : undefined, + blockedBy: !!blockedByUri, + blocking: blockingUri, + blockingByList: viewer.blockingByList + ? this.listBasic(viewer.blockingByList, state) + : undefined, + following: viewer.following && !block ? viewer.following : undefined, + followedBy: viewer.followedBy && !block ? viewer.followedBy : undefined, + } + } + + blockedProfileViewer( + did: string, + state: HydrationState, + ): ProfileViewerState | undefined { + const viewer = state.profileViewers?.get(did) + if (!viewer) return + const blockedByUri = viewer.blockedBy || viewer.blockedByList + const blockingUri = viewer.blocking || viewer.blockingByList + return { + blockedBy: !!blockedByUri, + blocking: blockingUri, + } + } + + // Graph + // ------------ + + list(uri: string, state: HydrationState): ListView | undefined { + const creatorDid = new AtUri(uri).hostname + const list = state.lists?.get(uri) + if (!list) return + const creator = this.profile(creatorDid, state) + if (!creator) return + const basicView = this.listBasic(uri, state) + if (!basicView) return + + return { + ...basicView, + creator, + description: list.record.description, + descriptionFacets: list.record.descriptionFacets, + indexedAt: list.sortedAt.toISOString(), + } + } + + listBasic(uri: string, state: HydrationState): ListViewBasic | undefined { + const list = state.lists?.get(uri) + if (!list) { + return undefined + } + const listViewer = state.listViewers?.get(uri) + const creator = new AtUri(uri).hostname + return { + uri, + cid: list.cid, + name: list.record.name, + purpose: list.record.purpose, + avatar: list.record.avatar + ? this.imgUriBuilder.getPresetUri( + 'avatar', + creator, + cidFromBlobJson(list.record.avatar), + ) + : undefined, + indexedAt: list.sortedAt.toISOString(), + viewer: listViewer + ? { + muted: !!listViewer.viewerMuted, + blocked: listViewer.viewerListBlockUri, + } + : undefined, + } + } + + // Labels + // ------------ + + selfLabels(details: { + uri?: string + cid?: string + record?: Record + }): Label[] { + const { uri, cid, record } = details + if (!uri || !cid || !record) return [] + if (!isSelfLabels(record.labels)) return [] + const src = new AtUri(uri).host // record creator + const cts = + typeof record.createdAt === 'string' + ? normalizeDatetimeAlways(record.createdAt) + : new Date(0).toISOString() + return record.labels.values.map(({ val }) => { + return { src, uri, cid, val, cts, neg: false } + }) + } + + // Feed + // ------------ + + feedItemBlocksAndMutes( + item: FeedItem, + state: HydrationState, + ): { + originatorMuted: boolean + originatorBlocked: boolean + authorMuted: boolean + authorBlocked: boolean + } { + const authorDid = creatorFromUri(item.post.uri) + const originatorDid = item.repost + ? creatorFromUri(item.repost.uri) + : authorDid + return { + originatorMuted: this.viewerMuteExists(originatorDid, state), + originatorBlocked: this.viewerBlockExists(originatorDid, state), + authorMuted: this.viewerMuteExists(authorDid, state), + authorBlocked: this.viewerBlockExists(authorDid, state), + } + } + + feedGenerator(uri: string, state: HydrationState): GeneratorView | undefined { + const feedgen = state.feedgens?.get(uri) + if (!feedgen) return + const creatorDid = creatorFromUri(uri) + const creator = this.profile(creatorDid, state) + if (!creator) return + const viewer = state.feedgenViewers?.get(uri) + const aggs = state.feedgenAggs?.get(uri) + + return { + uri, + cid: feedgen.cid, + did: feedgen.record.did, + creator, + displayName: feedgen.record.displayName, + description: feedgen.record.description, + descriptionFacets: feedgen.record.descriptionFacets, + avatar: feedgen.record.avatar + ? this.imgUriBuilder.getPresetUri( + 'avatar', + creatorDid, + cidFromBlobJson(feedgen.record.avatar), + ) + : undefined, + likeCount: aggs?.likes, + viewer: viewer + ? { + like: viewer.like, + } + : undefined, + indexedAt: feedgen.sortedAt.toISOString(), + } + } + + threadGate(uri: string, state: HydrationState): ThreadgateView | undefined { + const gate = state.threadgates?.get(uri) + if (!gate) return + return { + uri, + cid: gate.cid, + record: gate.record, + lists: mapDefined(gate.record.allow ?? [], (rule) => { + if (!isListRule(rule)) return + return this.listBasic(rule.list, state) + }), + } + } + + post(uri: string, state: HydrationState, depth = 0): PostView | undefined { + const post = state.posts?.get(uri) + if (!post) return + const parsedUri = new AtUri(uri) + const authorDid = parsedUri.hostname + const author = this.profileBasic(authorDid, state) + if (!author) return + const aggs = state.postAggs?.get(uri) + const viewer = state.postViewers?.get(uri) + const gateUri = AtUri.make( + authorDid, + ids.AppBskyFeedThreadgate, + parsedUri.rkey, + ).toString() + const labels = [ + ...(state.labels?.get(uri) ?? []), + ...this.selfLabels({ + uri, + cid: post.cid, + record: post.record, + }), + ] + return { + uri, + cid: post.cid, + author, + record: post.record, + embed: + depth < 2 && post.record.embed + ? this.embed(uri, post.record.embed, state, depth + 1) + : undefined, + replyCount: aggs?.replies, + repostCount: aggs?.reposts, + likeCount: aggs?.likes, + indexedAt: post.sortedAt.toISOString(), + viewer: viewer + ? { + repost: viewer.repost, + like: viewer.like, + replyDisabled: this.userReplyDisabled(uri, state), + } + : undefined, + labels, + threadgate: !post.record.reply // only hydrate gate on root post + ? this.threadGate(gateUri, state) + : undefined, + } + } + + feedViewPost( + item: FeedItem, + state: HydrationState, + ): FeedViewPost | undefined { + const postInfo = state.posts?.get(item.post.uri) + let reason: ReasonRepost | undefined + if (item.repost) { + const repost = state.reposts?.get(item.repost.uri) + if (!repost) return + if (repost.record.subject.uri !== item.post.uri) return + reason = this.reasonRepost(creatorFromUri(item.repost.uri), repost, state) + if (!reason) return + } + const post = this.post(item.post.uri, state) + if (!post) return + return { + post, + reason, + reply: !postInfo?.violatesThreadGate + ? this.replyRef(item.post.uri, state) + : undefined, + } + } + + replyRef(uri: string, state: HydrationState, usePostViewUnion = false) { + // don't hydrate reply if there isn't it violates a block + if (state.postBlocks?.get(uri)?.reply) return undefined + const postRecord = state.posts?.get(uri.toString())?.record + if (!postRecord?.reply) return + const root = this.maybePost( + postRecord.reply.root.uri, + state, + usePostViewUnion, + ) + const parent = this.maybePost( + postRecord.reply.parent.uri, + state, + usePostViewUnion, + ) + return root && parent ? { root, parent } : undefined + } + + maybePost( + uri: string, + state: HydrationState, + usePostViewUnion = false, + ): MaybePostView | undefined { + const post = this.post(uri, state) + if (!post) return usePostViewUnion ? this.notFoundPost(uri) : undefined + if (this.viewerBlockExists(post.author.did, state)) { + return usePostViewUnion + ? this.blockedPost(uri, post.author.did, state) + : undefined + } + return { + $type: 'app.bsky.feed.defs#postView', + ...post, + } + } + + blockedPost( + uri: string, + authorDid: string, + state: HydrationState, + ): BlockedPost { + return { + $type: 'app.bsky.feed.defs#blockedPost', + uri, + blocked: true, + author: { + did: authorDid, + viewer: this.blockedProfileViewer(authorDid, state), + }, + } + } + + notFoundPost(uri: string): NotFoundPost { + return { + $type: 'app.bsky.feed.defs#notFoundPost', + uri, + notFound: true, + } + } + + reasonRepost( + creatorDid: string, + repost: Repost, + state: HydrationState, + ): ReasonRepost | undefined { + const creator = this.profileBasic(creatorDid, state) + if (!creator) return + return { + $type: 'app.bsky.feed.defs#reasonRepost', + by: creator, + indexedAt: repost.sortedAt.toISOString(), + } + } + + // Threads + // ------------ + + thread( + skele: { anchor: string; uris: string[] }, + state: HydrationState, + opts: { height: number; depth: number }, + ): ThreadViewPost | NotFoundPost | BlockedPost { + const { anchor, uris } = skele + const post = this.post(anchor, state) + const postInfo = state.posts?.get(anchor) + if (!postInfo || !post) return this.notFoundPost(anchor) + if (this.viewerBlockExists(post.author.did, state)) { + return this.blockedPost(anchor, post.author.did, state) + } + const includedPosts = new Set([anchor]) + const childrenByParentUri: Record = {} + uris.forEach((uri) => { + const post = state.posts?.get(uri) + const parentUri = post?.record.reply?.parent.uri + if (!parentUri) return + if (includedPosts.has(uri)) return + includedPosts.add(uri) + childrenByParentUri[parentUri] ??= [] + childrenByParentUri[parentUri].push(uri) + }) + const rootUri = getRootUri(anchor, postInfo) + const violatesThreadGate = postInfo.violatesThreadGate + + return { + $type: 'app.bsky.feed.defs#threadViewPost', + post, + parent: !violatesThreadGate + ? this.threadParent(anchor, rootUri, state, opts.height) + : undefined, + replies: !violatesThreadGate + ? this.threadReplies( + anchor, + rootUri, + childrenByParentUri, + state, + opts.depth, + ) + : undefined, + } + } + + threadParent( + childUri: string, + rootUri: string, + state: HydrationState, + height: number, + ): ThreadViewPost | NotFoundPost | BlockedPost | undefined { + if (height < 1) return undefined + const parentUri = state.posts?.get(childUri)?.record.reply?.parent.uri + if (!parentUri) return undefined + if (state.postBlocks?.get(childUri)?.reply) { + return this.blockedPost(parentUri, creatorFromUri(parentUri), state) + } + const post = this.post(parentUri, state) + const postInfo = state.posts?.get(parentUri) + if (!postInfo || !post) return this.notFoundPost(parentUri) + if (rootUri !== getRootUri(parentUri, postInfo)) return // outside thread boundary + if (this.viewerBlockExists(post.author.did, state)) { + return this.blockedPost(parentUri, post.author.did, state) + } + return { + $type: 'app.bsky.feed.defs#threadViewPost', + post, + parent: this.threadParent(parentUri, rootUri, state, height - 1), + } + } + + threadReplies( + parentUri: string, + rootUri: string, + childrenByParentUri: Record, + state: HydrationState, + depth: number, + ): (ThreadViewPost | NotFoundPost | BlockedPost)[] | undefined { + if (depth < 1) return undefined + const childrenUris = childrenByParentUri[parentUri] ?? [] + return mapDefined(childrenUris, (uri) => { + const postInfo = state.posts?.get(uri) + if (postInfo?.violatesThreadGate) { + return undefined + } + if (state.postBlocks?.get(uri)?.reply) { + return undefined + } + const post = this.post(uri, state) + if (!postInfo || !post) return this.notFoundPost(uri) + if (rootUri !== getRootUri(uri, postInfo)) return // outside thread boundary + if (this.viewerBlockExists(post.author.did, state)) { + return this.blockedPost(uri, post.author.did, state) + } + return { + $type: 'app.bsky.feed.defs#threadViewPost', + post, + replies: this.threadReplies( + uri, + rootUri, + childrenByParentUri, + state, + depth - 1, + ), + } + }) + } + + // Embeds + // ------------ + + embed( + postUri: string, + embed: Embed | { $type: string }, + state: HydrationState, + depth: number, + ): EmbedView | undefined { + if (isImagesEmbed(embed)) { + return this.imagesEmbed(creatorFromUri(postUri), embed) + } else if (isExternalEmbed(embed)) { + return this.externalEmbed(creatorFromUri(postUri), embed) + } else if (isRecordEmbed(embed)) { + return this.recordEmbed(postUri, embed, state, depth) + } else if (isRecordWithMedia(embed)) { + return this.recordWithMediaEmbed(postUri, embed, state, depth) + } else { + return undefined + } + } + + imagesEmbed(did: string, embed: ImagesEmbed): ImagesEmbedView { + const imgViews = embed.images.map((img) => ({ + thumb: this.imgUriBuilder.getPresetUri( + 'feed_thumbnail', + did, + cidFromBlobJson(img.image), + ), + fullsize: this.imgUriBuilder.getPresetUri( + 'feed_fullsize', + did, + cidFromBlobJson(img.image), + ), + alt: img.alt, + aspectRatio: img.aspectRatio, + })) + return { + $type: 'app.bsky.embed.images#view', + images: imgViews, + } + } + + externalEmbed(did: string, embed: ExternalEmbed): ExternalEmbedView { + const { uri, title, description, thumb } = embed.external + return { + $type: 'app.bsky.embed.external#view', + external: { + uri, + title, + description, + thumb: thumb + ? this.imgUriBuilder.getPresetUri( + 'feed_thumbnail', + did, + cidFromBlobJson(thumb), + ) + : undefined, + }, + } + } + + embedNotFound(uri: string): { $type: string; record: EmbedNotFound } { + return { + $type: 'app.bsky.embed.record#view', + record: { + $type: 'app.bsky.embed.record#viewNotFound', + uri, + notFound: true, + }, + } + } + + embedBlocked( + uri: string, + state: HydrationState, + ): { $type: string; record: EmbedBlocked } { + const creator = creatorFromUri(uri) + return { + $type: 'app.bsky.embed.record#view', + record: { + $type: 'app.bsky.embed.record#viewBlocked', + uri, + blocked: true, + author: { + did: creator, + viewer: this.blockedProfileViewer(creator, state), + }, + }, + } + } + + embedPostView( + uri: string, + state: HydrationState, + depth: number, + ): PostEmbedView | undefined { + const postView = this.post(uri, state, depth) + if (!postView) return + return { + $type: 'app.bsky.embed.record#viewRecord', + uri: postView.uri, + cid: postView.cid, + author: postView.author, + value: postView.record, + labels: postView.labels, + indexedAt: postView.indexedAt, + embeds: depth > 1 ? undefined : postView.embed ? [postView.embed] : [], + } + } + + recordEmbed( + postUri: string, + embed: RecordEmbed, + state: HydrationState, + depth: number, + withTypeTag = true, + ): RecordEmbedView { + const uri = embed.record.uri + const parsedUri = new AtUri(uri) + if ( + this.viewerBlockExists(parsedUri.hostname, state) || + state.postBlocks?.get(postUri)?.embed + ) { + return this.embedBlocked(uri, state) + } + + if (parsedUri.collection === ids.AppBskyFeedPost) { + const view = this.embedPostView(uri, state, depth) + if (!view) return this.embedNotFound(uri) + return this.recordEmbedWrapper(view, withTypeTag) + } else if (parsedUri.collection === ids.AppBskyFeedGenerator) { + const view = this.feedGenerator(uri, state) + if (!view) return this.embedNotFound(uri) + view.$type = 'app.bsky.feed.defs#generatorView' + return this.recordEmbedWrapper(view, withTypeTag) + } else if (parsedUri.collection === ids.AppBskyGraphList) { + const view = this.list(uri, state) + if (!view) return this.embedNotFound(uri) + view.$type = 'app.bsky.graph.defs#listView' + return this.recordEmbedWrapper(view, withTypeTag) + } + return this.embedNotFound(uri) + } + + private recordEmbedWrapper( + record: RecordEmbedViewInternal, + withTypeTag: boolean, + ): RecordEmbedView { + return { + $type: withTypeTag ? 'app.bsky.embed.record#view' : undefined, + record, + } + } + + recordWithMediaEmbed( + postUri: string, + embed: RecordWithMedia, + state: HydrationState, + depth: number, + ): RecordWithMediaView | undefined { + const creator = creatorFromUri(postUri) + let mediaEmbed: ImagesEmbedView | ExternalEmbedView + if (isImagesEmbed(embed.media)) { + mediaEmbed = this.imagesEmbed(creator, embed.media) + } else if (isExternalEmbed(embed.media)) { + mediaEmbed = this.externalEmbed(creator, embed.media) + } else { + return + } + return { + $type: 'app.bsky.embed.recordWithMedia#view', + media: mediaEmbed, + record: this.recordEmbed(postUri, embed.record, state, depth, false), + } + } + + userReplyDisabled(uri: string, state: HydrationState): boolean | undefined { + const post = state.posts?.get(uri) + if (post?.violatesThreadGate) { + return true + } + const rootUriStr: string = post?.record.reply?.root.uri ?? uri + const gate = state.threadgates?.get(postToGateUri(rootUriStr))?.record + if (!gate || !state.viewer) { + return undefined + } + const rootPost = state.posts?.get(rootUriStr)?.record + const ownerDid = new AtUri(rootUriStr).hostname + const { + canReply, + allowFollowing, + allowListUris = [], + } = parseThreadGate(state.viewer, ownerDid, rootPost ?? null, gate) + if (canReply) { + return false + } + if (allowFollowing && state.profileViewers?.get(ownerDid)?.followedBy) { + return false + } + for (const listUri of allowListUris) { + const list = state.listViewers?.get(listUri) + if (list?.viewerInList) { + return false + } + } + return true + } + + notification( + notif: Notification, + lastSeenAt: string | undefined, + state: HydrationState, + ): NotificationView | undefined { + if (!notif.timestamp || !notif.reason) return + const uri = new AtUri(notif.uri) + const authorDid = uri.hostname + const author = this.profile(authorDid, state) + if (!author) return + let recordInfo: RecordInfo> | null | undefined + if (uri.collection === ids.AppBskyFeedPost) { + recordInfo = state.posts?.get(notif.uri) + } else if (uri.collection === ids.AppBskyFeedLike) { + recordInfo = state.likes?.get(notif.uri) + } else if (uri.collection === ids.AppBskyFeedRepost) { + recordInfo = state.reposts?.get(notif.uri) + } else if (uri.collection === ids.AppBskyGraphFollow) { + recordInfo = state.follows?.get(notif.uri) + } + if (!recordInfo) return + const labels = state.labels?.get(notif.uri) ?? [] + const selfLabels = this.selfLabels({ + uri: notif.uri, + cid: recordInfo.cid, + record: recordInfo.record, + }) + const indexedAt = notif.timestamp.toDate().toISOString() + return { + uri: notif.uri, + cid: recordInfo.cid, + author, + reason: notif.reason, + reasonSubject: notif.reasonSubject || undefined, + record: recordInfo.record, + // @NOTE works with a hack in listNotifications so that when there's no last-seen time, + // the user's first notification is marked unread, and all previous read. in this case, + // the last seen time will be equal to the first notification's indexed time. + isRead: lastSeenAt ? lastSeenAt > indexedAt : true, + indexedAt: notif.timestamp.toDate().toISOString(), + labels: [...labels, ...selfLabels], + } + } +} + +const postToGateUri = (uri: string) => { + const aturi = new AtUri(uri) + if (aturi.collection === ids.AppBskyFeedPost) { + aturi.collection = ids.AppBskyFeedThreadgate + } + return aturi.toString() +} + +const getRootUri = (uri: string, post: Post): string => { + return post.record.reply?.root.uri ?? uri +} diff --git a/packages/bsky/src/views/types.ts b/packages/bsky/src/views/types.ts new file mode 100644 index 00000000000..8c5a3deb026 --- /dev/null +++ b/packages/bsky/src/views/types.ts @@ -0,0 +1,72 @@ +import { + Main as ImagesEmbed, + View as ImagesEmbedView, +} from '../lexicon/types/app/bsky/embed/images' +import { + Main as ExternalEmbed, + View as ExternalEmbedView, +} from '../lexicon/types/app/bsky/embed/external' +import { + Main as RecordEmbed, + View as RecordEmbedView, + ViewBlocked as EmbedBlocked, + ViewNotFound as EmbedNotFound, + ViewRecord as PostEmbedView, +} from '../lexicon/types/app/bsky/embed/record' +import { + Main as RecordWithMedia, + View as RecordWithMediaView, +} from '../lexicon/types/app/bsky/embed/recordWithMedia' +import { + BlockedPost, + GeneratorView, + NotFoundPost, + PostView, +} from '../lexicon/types/app/bsky/feed/defs' +import { ListView } from '../lexicon/types/app/bsky/graph/defs' + +export type { + Main as ImagesEmbed, + View as ImagesEmbedView, +} from '../lexicon/types/app/bsky/embed/images' +export { isMain as isImagesEmbed } from '../lexicon/types/app/bsky/embed/images' +export type { + Main as ExternalEmbed, + View as ExternalEmbedView, +} from '../lexicon/types/app/bsky/embed/external' +export { isMain as isExternalEmbed } from '../lexicon/types/app/bsky/embed/external' +export type { + Main as RecordEmbed, + View as RecordEmbedView, + ViewBlocked as EmbedBlocked, + ViewNotFound as EmbedNotFound, + ViewRecord as PostEmbedView, +} from '../lexicon/types/app/bsky/embed/record' +export { isMain as isRecordEmbed } from '../lexicon/types/app/bsky/embed/record' +export type { + Main as RecordWithMedia, + View as RecordWithMediaView, +} from '../lexicon/types/app/bsky/embed/recordWithMedia' +export { isMain as isRecordWithMedia } from '../lexicon/types/app/bsky/embed/recordWithMedia' +export type { View as RecordWithMediaEmbedView } from '../lexicon/types/app/bsky/embed/recordWithMedia' +export type { + BlockedPost, + GeneratorView, + NotFoundPost, + PostView, +} from '../lexicon/types/app/bsky/feed/defs' +export type { ListView } from '../lexicon/types/app/bsky/graph/defs' + +export type { Notification as NotificationView } from '../lexicon/types/app/bsky/notification/listNotifications' + +export type Embed = ImagesEmbed | ExternalEmbed | RecordEmbed | RecordWithMedia + +export type EmbedView = + | ImagesEmbedView + | ExternalEmbedView + | RecordEmbedView + | RecordWithMediaView + +export type MaybePostView = PostView | NotFoundPost | BlockedPost + +export type RecordEmbedViewInternal = PostEmbedView | GeneratorView | ListView diff --git a/packages/bsky/src/views/util.ts b/packages/bsky/src/views/util.ts new file mode 100644 index 00000000000..6f63945ef0a --- /dev/null +++ b/packages/bsky/src/views/util.ts @@ -0,0 +1,64 @@ +import { AtUri } from '@atproto/syntax' +import { BlobRef } from '@atproto/lexicon' +import { Record as PostRecord } from '../lexicon/types/app/bsky/feed/post' +import { + Record as GateRecord, + isFollowingRule, + isListRule, + isMentionRule, +} from '../lexicon/types/app/bsky/feed/threadgate' +import { isMention } from '../lexicon/types/app/bsky/richtext/facet' + +export const creatorFromUri = (uri: string): string => { + return new AtUri(uri).hostname +} + +export const parseThreadGate = ( + replierDid: string, + ownerDid: string, + rootPost: PostRecord | null, + gate: GateRecord | null, +): ParsedThreadGate => { + if (replierDid === ownerDid) { + return { canReply: true } + } + // if gate.allow is unset then *any* reply is allowed, if it is an empty array then *no* reply is allowed + if (!gate || !gate.allow) { + return { canReply: true } + } + + const allowMentions = !!gate.allow.find(isMentionRule) + const allowFollowing = !!gate.allow.find(isFollowingRule) + const allowListUris = gate.allow?.filter(isListRule).map((item) => item.list) + + // check mentions first since it's quick and synchronous + if (allowMentions) { + const isMentioned = rootPost?.facets?.some((facet) => { + return facet.features.some( + (item) => isMention(item) && item.did === replierDid, + ) + }) + if (isMentioned) { + return { canReply: true, allowMentions, allowFollowing, allowListUris } + } + } + return { allowMentions, allowFollowing, allowListUris } +} + +type ParsedThreadGate = { + canReply?: boolean + allowMentions?: boolean + allowFollowing?: boolean + allowListUris?: string[] +} + +export const cidFromBlobJson = (json: BlobRef) => { + if (json instanceof BlobRef) { + return json.ref.toString() + } + // @NOTE below handles the fact that parseRecordBytes() produces raw json rather than lexicon values + if (json['$type'] === 'blob') { + return (json['ref']?.['$link'] ?? '') as string + } + return (json['cid'] ?? '') as string +} diff --git a/packages/bsky/tests/__snapshots__/feed-generation.test.ts.snap b/packages/bsky/tests/__snapshots__/feed-generation.test.ts.snap index 4ac043c9b88..8aea15ddfc8 100644 --- a/packages/bsky/tests/__snapshots__/feed-generation.test.ts.snap +++ b/packages/bsky/tests/__snapshots__/feed-generation.test.ts.snap @@ -65,9 +65,11 @@ Object { "cid": "cids(2)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "description": "its me!", "did": "user(3)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(3)", @@ -938,13 +940,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(4)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(4)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(4)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(4)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(5)@jpeg", }, ], }, @@ -952,8 +954,8 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", + "did": "user(4)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -1223,13 +1225,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(4)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(4)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(4)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(4)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(5)@jpeg", }, ], }, @@ -1237,8 +1239,8 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", + "did": "user(4)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -1457,9 +1459,11 @@ Object { "cid": "cids(0)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(1)@jpeg", + "description": "its me!", "did": "user(1)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", @@ -1505,9 +1509,11 @@ Object { "cid": "cids(0)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(1)@jpeg", + "description": "its me!", "did": "user(1)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", @@ -1533,23 +1539,23 @@ Object { "muted": false, }, }, - "description": "Provides all feed candidates", + "description": "Provides even-indexed feed candidates", "did": "user(0)", - "displayName": "All", + "displayName": "Even", "indexedAt": "1970-01-01T00:00:00.000Z", - "likeCount": 2, + "likeCount": 0, "uri": "record(0)", - "viewer": Object { - "like": "record(4)", - }, + "viewer": Object {}, }, Object { "cid": "cids(3)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(1)@jpeg", + "description": "its me!", "did": "user(1)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", @@ -1575,13 +1581,15 @@ Object { "muted": false, }, }, - "description": "Provides even-indexed feed candidates", + "description": "Provides all feed candidates", "did": "user(0)", - "displayName": "Even", + "displayName": "All", "indexedAt": "1970-01-01T00:00:00.000Z", - "likeCount": 0, - "uri": "record(5)", - "viewer": Object {}, + "likeCount": 2, + "uri": "record(4)", + "viewer": Object { + "like": "record(5)", + }, }, ], } @@ -1589,14 +1597,17 @@ Object { exports[`feed generation getSuggestedFeeds returns list of suggested feed generators 1`] = ` Object { + "cursor": "4", "feeds": Array [ Object { "cid": "cids(0)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(1)@jpeg", + "description": "its me!", "did": "user(1)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", @@ -1636,9 +1647,11 @@ Object { "cid": "cids(3)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(1)@jpeg", + "description": "its me!", "did": "user(1)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", @@ -1676,9 +1689,11 @@ Object { "cid": "cids(4)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(1)@jpeg", + "description": "its me!", "did": "user(1)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", diff --git a/packages/bsky/tests/_util.ts b/packages/bsky/tests/_util.ts index 8d39a0f9c2c..a86d47a04f6 100644 --- a/packages/bsky/tests/_util.ts +++ b/packages/bsky/tests/_util.ts @@ -49,7 +49,7 @@ export const forSnapshot = (obj: unknown) => { return constantDate } } - if (str.match(/^\d+::bafy/)) { + if (str.match(/^\d+__bafy/)) { return constantKeysetCursor } if (str.match(/\/img\/[^/]+\/.+\/did:plc:[^/]+\/[^/]+@[\w]+$/)) { @@ -110,7 +110,7 @@ export function take( } export const constantDate = new Date(0).toISOString() -export const constantKeysetCursor = '0000000000000::bafycid' +export const constantKeysetCursor = '0000000000000__bafycid' const mapLeafValues = (obj: unknown, fn: (val: unknown) => unknown) => { if (Array.isArray(obj)) { diff --git a/packages/bsky/tests/admin/admin-auth.test.ts b/packages/bsky/tests/admin/admin-auth.test.ts index ff00d0906b0..cb13b58897a 100644 --- a/packages/bsky/tests/admin/admin-auth.test.ts +++ b/packages/bsky/tests/admin/admin-auth.test.ts @@ -27,15 +27,30 @@ describe('admin auth', () => { bskyDid = network.bsky.ctx.cfg.serverDid modServiceKey = await Secp256k1Keypair.create() - const origResolve = network.bsky.ctx.idResolver.did.resolveAtprotoKey - network.bsky.ctx.idResolver.did.resolveAtprotoKey = async ( + const origResolve = network.bsky.dataplane.idResolver.did.resolve + network.bsky.dataplane.idResolver.did.resolve = async function ( did: string, forceRefresh?: boolean, - ) => { + ) { if (did === modServiceDid || did === altModDid) { - return modServiceKey.did() + return { + '@context': [ + 'https://www.w3.org/ns/did/v1', + 'https://w3id.org/security/multikey/v1', + 'https://w3id.org/security/suites/secp256k1-2019/v1', + ], + id: did, + verificationMethod: [ + { + id: `${did}#atproto`, + type: 'Multikey', + controller: did, + publicKeyMultibase: modServiceKey.did().replace('did:key:', ''), + }, + ], + } } - return origResolve(did, forceRefresh) + return origResolve.call(this, did, forceRefresh) } agent = network.bsky.getClient() @@ -70,9 +85,7 @@ describe('admin auth', () => { ) const res = await agent.api.com.atproto.admin.getSubjectStatus( - { - did: repoSubject.did, - }, + { did: repoSubject.did }, headers, ) expect(res.data.subject.did).toBe(repoSubject.did) diff --git a/packages/bsky/tests/admin/moderation.test.ts b/packages/bsky/tests/admin/moderation.test.ts index 6b01bfbbcb6..2f6caa8d17d 100644 --- a/packages/bsky/tests/admin/moderation.test.ts +++ b/packages/bsky/tests/admin/moderation.test.ts @@ -55,18 +55,18 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.bsky.adminAuthHeaders(), }, ) const res = await agent.api.com.atproto.admin.getSubjectStatus( { did: repoSubject.did, }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.bsky.adminAuthHeaders() }, ) expect(res.data.subject.did).toEqual(sc.dids.bob) expect(res.data.takedown?.applied).toBe(true) - expect(res.data.takedown?.ref).toBe('test-repo') + // expect(res.data.takedown?.ref).toBe('test-repo') @TODO add these checks back in once takedown refs make it into dataplane }) it('restores takendown accounts', async () => { @@ -77,14 +77,14 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.bsky.adminAuthHeaders(), }, ) const res = await agent.api.com.atproto.admin.getSubjectStatus( { did: repoSubject.did, }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.bsky.adminAuthHeaders() }, ) expect(res.data.subject.did).toEqual(sc.dids.bob) expect(res.data.takedown?.applied).toBe(false) @@ -99,18 +99,18 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.bsky.adminAuthHeaders(), }, ) const res = await agent.api.com.atproto.admin.getSubjectStatus( { uri: recordSubject.uri, }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.bsky.adminAuthHeaders() }, ) expect(res.data.subject.uri).toEqual(recordSubject.uri) expect(res.data.takedown?.applied).toBe(true) - expect(res.data.takedown?.ref).toBe('test-record') + // expect(res.data.takedown?.ref).toBe('test-record') }) it('restores takendown records', async () => { @@ -121,55 +121,27 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.bsky.adminAuthHeaders(), }, ) const res = await agent.api.com.atproto.admin.getSubjectStatus( { uri: recordSubject.uri, }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.bsky.adminAuthHeaders() }, ) expect(res.data.subject.uri).toEqual(recordSubject.uri) expect(res.data.takedown?.applied).toBe(false) expect(res.data.takedown?.ref).toBeUndefined() }) - it('does not allow non-full moderators to update subject state', async () => { - const subject = { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - } - const attemptTakedownTriage = - agent.api.com.atproto.admin.updateSubjectStatus( - { - subject, - takedown: { applied: true }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('triage'), - }, - ) - await expect(attemptTakedownTriage).rejects.toThrow( - 'Must be a full moderator to update subject state', - ) - const res = await agent.api.com.atproto.admin.getSubjectStatus( - { - did: subject.did, - }, - { headers: network.bsky.adminAuthHeaders('moderator') }, - ) - expect(res.data.takedown?.applied).toBe(false) - }) - describe('blob takedown', () => { let blobUri: string let imageUri: string beforeAll(async () => { blobUri = `${network.bsky.url}/blob/${blobSubject.did}/${blobSubject.cid}` - imageUri = network.bsky.ctx.imgUriBuilder + imageUri = network.bsky.ctx.views.imgUriBuilder .getPresetUri('feed_thumbnail', blobSubject.did, blobSubject.cid) .replace(network.bsky.ctx.cfg.publicUrl || '', network.bsky.url) // Warm image server cache @@ -194,12 +166,12 @@ describe('moderation', () => { did: blobSubject.did, blob: blobSubject.cid, }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.bsky.adminAuthHeaders() }, ) expect(res.data.subject.did).toEqual(blobSubject.did) expect(res.data.subject.cid).toEqual(blobSubject.cid) expect(res.data.takedown?.applied).toBe(true) - expect(res.data.takedown?.ref).toBe('test-blob') + // expect(res.data.takedown?.ref).toBe('test-blob') }) it('prevents resolution of blob', async () => { @@ -211,12 +183,6 @@ describe('moderation', () => { }) }) - it('prevents image blob from being served, even when cached.', async () => { - const fetchImage = await fetch(imageUri) - expect(fetchImage.status).toEqual(404) - expect(await fetchImage.json()).toEqual({ message: 'Image not found' }) - }) - it('restores blob when takedown is removed', async () => { await agent.api.com.atproto.admin.updateSubjectStatus( { diff --git a/packages/bsky/tests/auth.test.ts b/packages/bsky/tests/auth.test.ts index e08049fa84c..d0903174a2b 100644 --- a/packages/bsky/tests/auth.test.ts +++ b/packages/bsky/tests/auth.test.ts @@ -22,7 +22,8 @@ describe('auth', () => { await network.close() }) - it('handles signing key change for service auth.', async () => { + // @TODO invalidations do not originate from appview frontends: requires identity event on the repo stream. + it.skip('handles signing key change for service auth.', async () => { const issuer = sc.dids.alice const attemptWithKey = async (keypair: Keypair) => { const jwt = await createServiceJwt({ diff --git a/packages/bsky/tests/auto-moderator/fixtures/hiveai_resp_example.json b/packages/bsky/tests/auto-moderator/fixtures/hiveai_resp_example.json deleted file mode 100644 index 2315fa9d0c0..00000000000 --- a/packages/bsky/tests/auto-moderator/fixtures/hiveai_resp_example.json +++ /dev/null @@ -1,401 +0,0 @@ -{ - "id": "02122580-c37f-11ed-81d2-000000000000", - "code": 200, - "project_id": 12345, - "user_id": 12345, - "created_on": "2023-03-15T22:16:18.408Z", - "status": [ - { - "status": { - "code": "0", - "message": "SUCCESS" - }, - "response": { - "input": { - "id": "02122580-c37f-11ed-81d2-000000000000", - "charge": 0.003, - "model": "mod55_dense", - "model_version": 1, - "model_type": "CATEGORIZATION", - "created_on": "2023-03-15T22:16:18.136Z", - "media": { - "url": null, - "filename": "bafkreiam7k6mvkyuoybq4ynhljvj5xa75sdbhjbolzjf5j2udx7vj5gnsy", - "type": "PHOTO", - "mime_type": "jpeg", - "mimetype": "image/jpeg", - "width": 800, - "height": 800, - "num_frames": 1, - "duration": 0 - }, - "user_id": 12345, - "project_id": 12345, - "config_version": 1, - "config_tag": "default" - }, - "output": [ - { - "time": 0, - "classes": [ - { - "class": "general_not_nsfw_not_suggestive", - "score": 0.9998097218132356 - }, - { - "class": "general_nsfw", - "score": 8.857344804177162e-5 - }, - { - "class": "general_suggestive", - "score": 0.00010170473872266839 - }, - { - "class": "no_female_underwear", - "score": 0.9999923079040384 - }, - { - "class": "yes_female_underwear", - "score": 7.692095961599136e-6 - }, - { - "class": "no_male_underwear", - "score": 0.9999984904867634 - }, - { - "class": "yes_male_underwear", - "score": 1.5095132367094679e-6 - }, - { - "class": "no_sex_toy", - "score": 0.9999970970762551 - }, - { - "class": "yes_sex_toy", - "score": 2.9029237450490604e-6 - }, - { - "class": "no_female_nudity", - "score": 0.9999739028909301 - }, - { - "class": "yes_female_nudity", - "score": 2.60971090699536e-5 - }, - { - "class": "no_male_nudity", - "score": 0.9999711373083747 - }, - { - "class": "yes_male_nudity", - "score": 2.8862691625255323e-5 - }, - { - "class": "no_female_swimwear", - "score": 0.9999917609899659 - }, - { - "class": "yes_female_swimwear", - "score": 8.239010034025379e-6 - }, - { - "class": "no_male_shirtless", - "score": 0.9999583350744331 - }, - { - "class": "yes_male_shirtless", - "score": 4.166492556688088e-5 - }, - { - "class": "no_text", - "score": 0.9958378716447616 - }, - { - "class": "text", - "score": 0.0041621283552384265 - }, - { - "class": "animated", - "score": 0.46755478950048235 - }, - { - "class": "hybrid", - "score": 0.0011440363434524984 - }, - { - "class": "natural", - "score": 0.5313011741560651 - }, - { - "class": "animated_gun", - "score": 2.0713000782979496e-5 - }, - { - "class": "gun_in_hand", - "score": 1.5844730446534659e-6 - }, - { - "class": "gun_not_in_hand", - "score": 1.0338973818006654e-6 - }, - { - "class": "no_gun", - "score": 0.9999766686287906 - }, - { - "class": "culinary_knife_in_hand", - "score": 3.8063500083369785e-6 - }, - { - "class": "culinary_knife_not_in_hand", - "score": 7.94057948996249e-7 - }, - { - "class": "knife_in_hand", - "score": 4.5578955723278505e-7 - }, - { - "class": "knife_not_in_hand", - "score": 3.842124714748908e-7 - }, - { - "class": "no_knife", - "score": 0.999994559590014 - }, - { - "class": "a_little_bloody", - "score": 2.1317745626539786e-7 - }, - { - "class": "no_blood", - "score": 0.9999793341236429 - }, - { - "class": "other_blood", - "score": 2.0322054269591763e-5 - }, - { - "class": "very_bloody", - "score": 1.306446309561673e-7 - }, - { - "class": "no_pills", - "score": 0.9999989592376954 - }, - { - "class": "yes_pills", - "score": 1.0407623044588633e-6 - }, - { - "class": "no_smoking", - "score": 0.9999939101969173 - }, - { - "class": "yes_smoking", - "score": 6.089803082758281e-6 - }, - { - "class": "illicit_injectables", - "score": 6.925695592003094e-7 - }, - { - "class": "medical_injectables", - "score": 8.587808234452378e-7 - }, - { - "class": "no_injectables", - "score": 0.9999984486496174 - }, - { - "class": "no_nazi", - "score": 0.9999987449628097 - }, - { - "class": "yes_nazi", - "score": 1.2550371902234279e-6 - }, - { - "class": "no_kkk", - "score": 0.999999762417549 - }, - { - "class": "yes_kkk", - "score": 2.3758245111050425e-7 - }, - { - "class": "no_middle_finger", - "score": 0.9999881515231847 - }, - { - "class": "yes_middle_finger", - "score": 1.184847681536747e-5 - }, - { - "class": "no_terrorist", - "score": 0.9999998870793229 - }, - { - "class": "yes_terrorist", - "score": 1.1292067715380635e-7 - }, - { - "class": "no_overlay_text", - "score": 0.9996453363440359 - }, - { - "class": "yes_overlay_text", - "score": 0.0003546636559640924 - }, - { - "class": "no_sexual_activity", - "score": 0.9999563580374798 - }, - { - "class": "yes_sexual_activity", - "score": 0.99, - "realScore": 4.364196252012032e-5 - }, - { - "class": "hanging", - "score": 3.6435135762510905e-7 - }, - { - "class": "no_hanging_no_noose", - "score": 0.9999980779196416 - }, - { - "class": "noose", - "score": 1.5577290007796094e-6 - }, - { - "class": "no_realistic_nsfw", - "score": 0.9999944341007805 - }, - { - "class": "yes_realistic_nsfw", - "score": 5.565899219571182e-6 - }, - { - "class": "animated_corpse", - "score": 5.276802046755426e-7 - }, - { - "class": "human_corpse", - "score": 2.5449360984211012e-8 - }, - { - "class": "no_corpse", - "score": 0.9999994468704343 - }, - { - "class": "no_self_harm", - "score": 0.9999994515625507 - }, - { - "class": "yes_self_harm", - "score": 5.484374493605692e-7 - }, - { - "class": "no_drawing", - "score": 0.9978276028816608 - }, - { - "class": "yes_drawing", - "score": 0.0021723971183392485 - }, - { - "class": "no_emaciated_body", - "score": 0.9999998146500432 - }, - { - "class": "yes_emaciated_body", - "score": 1.853499568724518e-7 - }, - { - "class": "no_child_present", - "score": 0.9999970498515446 - }, - { - "class": "yes_child_present", - "score": 2.950148455380443e-6 - }, - { - "class": "no_sexual_intent", - "score": 0.9999963861546292 - }, - { - "class": "yes_sexual_intent", - "score": 3.613845370766111e-6 - }, - { - "class": "animal_genitalia_and_human", - "score": 2.255472023465222e-8 - }, - { - "class": "animal_genitalia_only", - "score": 4.6783185199931176e-7 - }, - { - "class": "animated_animal_genitalia", - "score": 6.707857419436447e-7 - }, - { - "class": "no_animal_genitalia", - "score": 0.9999988388276858 - }, - { - "class": "no_gambling", - "score": 0.9999960939687145 - }, - { - "class": "yes_gambling", - "score": 3.906031285604864e-6 - }, - { - "class": "no_undressed", - "score": 0.99999923356218 - }, - { - "class": "yes_undressed", - "score": 7.664378199789045e-7 - }, - { - "class": "no_confederate", - "score": 0.9999925456900376 - }, - { - "class": "yes_confederate", - "score": 7.454309962453175e-6 - }, - { - "class": "animated_alcohol", - "score": 1.8109949948066074e-6 - }, - { - "class": "no_alcohol", - "score": 0.9999916620957963 - }, - { - "class": "yes_alcohol", - "score": 5.88781463445443e-6 - }, - { - "class": "yes_drinking_alcohol", - "score": 6.390945746578106e-7 - }, - { - "class": "no_religious_icon", - "score": 0.9999862158580689 - }, - { - "class": "yes_religious_icon", - "score": 1.3784141931119298e-5 - } - ] - } - ] - } - } - ], - "from_cache": false -} diff --git a/packages/bsky/tests/auto-moderator/hive.test.ts b/packages/bsky/tests/auto-moderator/hive.test.ts deleted file mode 100644 index 3a5cef45a37..00000000000 --- a/packages/bsky/tests/auto-moderator/hive.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import fs from 'fs/promises' -import * as hive from '../../src/auto-moderator/hive' - -describe('labeling', () => { - it('correctly parses hive responses', async () => { - const exampleRespBytes = await fs.readFile( - 'tests/auto-moderator/fixtures/hiveai_resp_example.json', - ) - const exampleResp = JSON.parse(exampleRespBytes.toString()) - const classes = hive.respToClasses(exampleResp) - expect(classes.length).toBeGreaterThan(10) - - const labels = hive.summarizeLabels(classes) - expect(labels).toEqual(['porn']) - }) -}) diff --git a/packages/bsky/tests/auto-moderator/labeler.test.ts b/packages/bsky/tests/auto-moderator/labeler.test.ts deleted file mode 100644 index ceeee8474b2..00000000000 --- a/packages/bsky/tests/auto-moderator/labeler.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { TestNetwork, usersSeed } from '@atproto/dev-env' -import { AtUri, BlobRef } from '@atproto/api' -import { Readable } from 'stream' -import { AutoModerator } from '../../src/auto-moderator' -import IndexerContext from '../../src/indexer/context' -import { cidForRecord } from '@atproto/repo' -import { TID } from '@atproto/common' -import { CID } from 'multiformats/cid' -import { ImgLabeler } from '../../src/auto-moderator/hive' -import { TestOzone } from '@atproto/dev-env/src/ozone' - -// outside of test suite so that TestLabeler can access them -let badCid1: CID | undefined = undefined -let badCid2: CID | undefined = undefined - -describe('labeler', () => { - let network: TestNetwork - let ozone: TestOzone - let autoMod: AutoModerator - let ctx: IndexerContext - let badBlob1: BlobRef - let badBlob2: BlobRef - let goodBlob: BlobRef - let alice: string - const postUri = () => AtUri.make(alice, 'app.bsky.feed.post', TID.nextStr()) - - beforeAll(async () => { - network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_labeler', - }) - ozone = network.ozone - ctx = network.bsky.indexer.ctx - const pdsCtx = network.pds.ctx - autoMod = ctx.autoMod - autoMod.imgLabeler = new TestImgLabeler() - const sc = network.getSeedClient() - await usersSeed(sc) - await network.processAll() - alice = sc.dids.alice - const storeBlob = (bytes: Uint8Array) => { - return pdsCtx.actorStore.transact(alice, async (store) => { - const metadata = await store.repo.blob.uploadBlobAndGetMetadata( - 'image/jpeg', - Readable.from([bytes], { objectMode: false }), - ) - const blobRef = await store.repo.blob.trackUntetheredBlob(metadata) - const preparedBlobRef = { - cid: blobRef.ref, - mimeType: 'image/jpeg', - constraints: {}, - } - await store.repo.blob.verifyBlobAndMakePermanent(preparedBlobRef) - await store.repo.blob.associateBlob(preparedBlobRef, postUri()) - return blobRef - }) - } - const bytes1 = new Uint8Array([1, 2, 3, 4]) - const bytes2 = new Uint8Array([5, 6, 7, 8]) - const bytes3 = new Uint8Array([4, 3, 2, 1]) - badBlob1 = await storeBlob(bytes1) - badBlob2 = await storeBlob(bytes2) - goodBlob = await storeBlob(bytes3) - badCid1 = badBlob1.ref - badCid2 = badBlob2.ref - }) - - afterAll(async () => { - await network.close() - }) - - const getLabels = async (subject: string) => { - return ozone.ctx.db.db - .selectFrom('label') - .selectAll() - .where('uri', '=', subject) - .execute() - } - - it('labels text in posts', async () => { - const post = { - $type: 'app.bsky.feed.post', - text: 'blah blah label_me', - createdAt: new Date().toISOString(), - } - const cid = await cidForRecord(post) - const uri = postUri() - autoMod.processRecord(uri, cid, post) - await network.processAll() - const labels = await getLabels(uri.toString()) - expect(labels.length).toBe(1) - expect(labels[0]).toMatchObject({ - src: ozone.ctx.cfg.service.did, - uri: uri.toString(), - cid: cid.toString(), - val: 'test-label', - neg: false, - }) - - // Verify that along with applying the labels, we are also leaving trace of the label as moderation event - // Temporarily assign an instance of moderation service to the autoMod so that we can validate label event - const modSrvc = ozone.ctx.modService(ozone.ctx.db) - const { events } = await modSrvc.getEvents({ - includeAllUserRecords: false, - subject: uri.toString(), - limit: 10, - types: [], - addedLabels: [], - removedLabels: [], - addedTags: [], - removedTags: [], - }) - expect(events.length).toBe(1) - expect(events[0]).toMatchObject({ - action: 'com.atproto.admin.defs#modEventLabel', - subjectUri: uri.toString(), - createLabelVals: 'test-label', - negateLabelVals: null, - comment: `[AutoModerator]: Applying labels`, - createdBy: network.bsky.indexer.ctx.cfg.serverDid, - }) - }) - - it('labels embeds in posts', async () => { - const post = { - $type: 'app.bsky.feed.post', - text: 'blah blah', - embed: { - $type: 'app.bsky.embed.images', - images: [ - { - image: badBlob1, - alt: 'img', - }, - { - image: badBlob2, - alt: 'label_me_2', - }, - { - image: goodBlob, - alt: 'img', - }, - ], - }, - createdAt: new Date().toISOString(), - } - const uri = postUri() - const cid = await cidForRecord(post) - autoMod.processRecord(uri, cid, post) - await autoMod.processAll() - const dbLabels = await getLabels(uri.toString()) - const labels = dbLabels.map((row) => row.val).sort() - expect(labels).toEqual( - ['test-label', 'test-label-2', 'img-label', 'other-img-label'].sort(), - ) - }) -}) - -class TestImgLabeler implements ImgLabeler { - async labelImg(_did: string, cid: CID): Promise { - if (cid.equals(badCid1)) { - return ['img-label'] - } - if (cid.equals(badCid2)) { - return ['other-img-label'] - } - return [] - } -} diff --git a/packages/bsky/tests/blob-resolver.test.ts b/packages/bsky/tests/blob-resolver.test.ts index e428c70ca08..985f347f7c2 100644 --- a/packages/bsky/tests/blob-resolver.test.ts +++ b/packages/bsky/tests/blob-resolver.test.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from 'axios' import { CID } from 'multiformats/cid' -import { verifyCidForBytes } from '@atproto/common' +import { cidForCbor, verifyCidForBytes } from '@atproto/common' import { TestNetwork, basicSeed } from '@atproto/dev-env' import { randomBytes } from '@atproto/crypto' @@ -17,7 +17,6 @@ describe('blob resolver', () => { const sc = network.getSeedClient() await basicSeed(sc) await network.processAll() - await network.bsky.processAll() fileDid = sc.dids.carol fileCid = sc.posts[fileDid][0].images[0].image.ref client = axios.create({ @@ -45,8 +44,9 @@ describe('blob resolver', () => { }) it('404s on missing blob.', async () => { + const badCid = await cidForCbor({ unknown: true }) const { data, status } = await client.get( - `/blob/did:plc:unknown/${fileCid.toString()}`, + `/blob/${fileDid}/${badCid.toString()}`, ) expect(status).toEqual(404) expect(data).toEqual({ @@ -55,6 +55,17 @@ describe('blob resolver', () => { }) }) + it('404s on missing identity.', async () => { + const { data, status } = await client.get( + `/blob/did:plc:unknown/${fileCid.toString()}`, + ) + expect(status).toEqual(404) + expect(data).toEqual({ + error: 'NotFoundError', + message: 'Origin not found', + }) + }) + it('400s on invalid did.', async () => { const { data, status } = await client.get( `/blob/did::/${fileCid.toString()}`, diff --git a/packages/bsky/tests/daemon.test.ts b/packages/bsky/tests/daemon.test.ts deleted file mode 100644 index cb3c7058cff..00000000000 --- a/packages/bsky/tests/daemon.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import assert from 'assert' -import { AtUri } from '@atproto/api' -import { TestNetwork, usersSeed } from '@atproto/dev-env' -import { BskyDaemon, DaemonConfig, PrimaryDatabase } from '../src' -import { countAll, excluded } from '../src/db/util' -import { NotificationsDaemon } from '../src/daemon/notifications' -import { - BEFORE_LAST_SEEN_DAYS, - BEFORE_LATEST_UNREAD_DAYS, - UNREAD_KEPT_COUNT, -} from '../src/services/util/notification' - -describe('daemon', () => { - let network: TestNetwork - let daemon: BskyDaemon - let db: PrimaryDatabase - let actors: { did: string }[] = [] - - beforeAll(async () => { - network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_daemon', - }) - db = network.bsky.ctx.db.getPrimary() - daemon = BskyDaemon.create({ - db, - cfg: new DaemonConfig({ - version: network.bsky.ctx.cfg.version, - dbPostgresUrl: network.bsky.ctx.cfg.dbPrimaryPostgresUrl, - dbPostgresSchema: network.bsky.ctx.cfg.dbPostgresSchema, - }), - }) - const sc = network.getSeedClient() - await usersSeed(sc) - await network.processAll() - actors = await db.db.selectFrom('actor').selectAll().execute() - }) - - afterAll(async () => { - await network.close() - }) - - describe('notifications daemon', () => { - it('processes all dids', async () => { - for (const { did } of actors) { - await Promise.all([ - setLastSeen(daemon.ctx.db, { did }), - createNotifications(daemon.ctx.db, { - did, - daysAgo: 2 * BEFORE_LAST_SEEN_DAYS, - count: 1, - }), - ]) - } - await expect(countNotifications(db)).resolves.toBe(actors.length) - await runNotifsOnce(daemon.notifications) - await expect(countNotifications(db)).resolves.toBe(0) - }) - - it('removes read notifications older than threshold.', async () => { - const { did } = actors[0] - const lastSeenDaysAgo = 10 - await Promise.all([ - setLastSeen(daemon.ctx.db, { did, daysAgo: lastSeenDaysAgo }), - // read, delete - createNotifications(daemon.ctx.db, { - did, - daysAgo: lastSeenDaysAgo + BEFORE_LAST_SEEN_DAYS + 1, - count: 2, - }), - // read, keep - createNotifications(daemon.ctx.db, { - did, - daysAgo: lastSeenDaysAgo + BEFORE_LAST_SEEN_DAYS - 1, - count: 3, - }), - // unread, keep - createNotifications(daemon.ctx.db, { - did, - daysAgo: lastSeenDaysAgo - 1, - count: 4, - }), - ]) - await expect(countNotifications(db)).resolves.toBe(9) - await runNotifsOnce(daemon.notifications) - await expect(countNotifications(db)).resolves.toBe(7) - await clearNotifications(db) - }) - - it('removes unread notifications older than threshold.', async () => { - const { did } = actors[0] - await Promise.all([ - setLastSeen(daemon.ctx.db, { - did, - daysAgo: 2 * BEFORE_LATEST_UNREAD_DAYS, // all are unread - }), - createNotifications(daemon.ctx.db, { - did, - daysAgo: 0, - count: 1, - }), - createNotifications(daemon.ctx.db, { - did, - daysAgo: BEFORE_LATEST_UNREAD_DAYS - 1, - count: 99, - }), - createNotifications(daemon.ctx.db, { - did, - daysAgo: BEFORE_LATEST_UNREAD_DAYS + 1, - count: 400, - }), - ]) - await expect(countNotifications(db)).resolves.toBe(UNREAD_KEPT_COUNT) - await runNotifsOnce(daemon.notifications) - // none removed when within UNREAD_KEPT_COUNT - await expect(countNotifications(db)).resolves.toBe(UNREAD_KEPT_COUNT) - // add one more, tip over UNREAD_KEPT_COUNT - await createNotifications(daemon.ctx.db, { - did, - daysAgo: BEFORE_LATEST_UNREAD_DAYS + 1, - count: 1, - }) - await runNotifsOnce(daemon.notifications) - // removed all older than BEFORE_LATEST_UNREAD_DAYS - await expect(countNotifications(db)).resolves.toBe(100) - await clearNotifications(db) - }) - }) - - const runNotifsOnce = async (notifsDaemon: NotificationsDaemon) => { - assert(!notifsDaemon.running, 'notifications daemon is already running') - notifsDaemon.run({ forever: false, batchSize: 2 }) - await notifsDaemon.running - } - - const setLastSeen = async ( - db: PrimaryDatabase, - opts: { did: string; daysAgo?: number }, - ) => { - const { did, daysAgo = 0 } = opts - const lastSeenAt = new Date() - lastSeenAt.setDate(lastSeenAt.getDate() - daysAgo) - await db.db - .insertInto('actor_state') - .values({ did, lastSeenNotifs: lastSeenAt.toISOString() }) - .onConflict((oc) => - oc.column('did').doUpdateSet({ - lastSeenNotifs: excluded(db.db, 'lastSeenNotifs'), - }), - ) - .execute() - } - - const createNotifications = async ( - db: PrimaryDatabase, - opts: { - did: string - count: number - daysAgo: number - }, - ) => { - const { did, count, daysAgo } = opts - const sortAt = new Date() - sortAt.setDate(sortAt.getDate() - daysAgo) - await db.db - .insertInto('notification') - .values( - [...Array(count)].map(() => ({ - did, - author: did, - reason: 'none', - recordCid: 'bafycid', - recordUri: AtUri.make(did, 'invalid.collection', 'self').toString(), - sortAt: sortAt.toISOString(), - })), - ) - .execute() - } - - const clearNotifications = async (db: PrimaryDatabase) => { - await db.db.deleteFrom('notification').execute() - } - - const countNotifications = async (db: PrimaryDatabase) => { - const { count } = await db.db - .selectFrom('notification') - .select(countAll.as('count')) - .executeTakeFirstOrThrow() - return count - } -}) diff --git a/packages/bsky/tests/__snapshots__/indexing.test.ts.snap b/packages/bsky/tests/data-plane/__snapshots__/indexing.test.ts.snap similarity index 98% rename from packages/bsky/tests/__snapshots__/indexing.test.ts.snap rename to packages/bsky/tests/data-plane/__snapshots__/indexing.test.ts.snap index 142866aeebd..e927f120505 100644 --- a/packages/bsky/tests/__snapshots__/indexing.test.ts.snap +++ b/packages/bsky/tests/data-plane/__snapshots__/indexing.test.ts.snap @@ -17,7 +17,7 @@ Array [ }, }, Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "feed": Array [ Object { "post": Object { @@ -113,7 +113,7 @@ Array [ "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label", }, @@ -121,7 +121,7 @@ Array [ "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label-2", }, @@ -207,7 +207,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(4)", + "did": "user(3)", "handle": "dan.test", "labels": Array [], "viewer": Object { @@ -223,7 +223,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(5)", + "did": "user(4)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -317,7 +317,7 @@ Array [ "cid": "cids(6)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(6)", "val": "test-label", }, @@ -413,10 +413,10 @@ Array [ ], }, Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "follows": Array [ Object { - "did": "user(4)", + "did": "user(3)", "handle": "dan.test", "labels": Array [], "viewer": Object { diff --git a/packages/bsky/tests/db.test.ts b/packages/bsky/tests/data-plane/db.test.ts similarity index 65% rename from packages/bsky/tests/db.test.ts rename to packages/bsky/tests/data-plane/db.test.ts index 28008f9897e..1a095906a98 100644 --- a/packages/bsky/tests/db.test.ts +++ b/packages/bsky/tests/data-plane/db.test.ts @@ -1,20 +1,17 @@ -import { once } from 'events' import { sql } from 'kysely' import { wait } from '@atproto/common' import { TestNetwork } from '@atproto/dev-env' -import { Database } from '../src' -import { PrimaryDatabase } from '../src/db' -import { Leader } from '../src/db/leader' +import { Database } from '../../src' describe('db', () => { let network: TestNetwork - let db: PrimaryDatabase + let db: Database beforeAll(async () => { network = await TestNetwork.create({ dbPostgresSchema: 'bsky_db', }) - db = network.bsky.ctx.db.getPrimary() + db = network.bsky.db }) afterAll(async () => { @@ -189,80 +186,4 @@ describe('db', () => { expect(res.length).toBe(0) }) }) - - describe('Leader', () => { - it('allows leaders to run sequentially.', async () => { - const task = async () => { - await wait(25) - return 'complete' - } - const leader1 = new Leader(707, db) - const leader2 = new Leader(707, db) - const leader3 = new Leader(707, db) - const result1 = await leader1.run(task) - await wait(5) // Short grace period for pg to close session - const result2 = await leader2.run(task) - await wait(5) - const result3 = await leader3.run(task) - await wait(5) - const result4 = await leader3.run(task) - expect([result1, result2, result3, result4]).toEqual([ - { ran: true, result: 'complete' }, - { ran: true, result: 'complete' }, - { ran: true, result: 'complete' }, - { ran: true, result: 'complete' }, - ]) - }) - - it('only allows one leader at a time.', async () => { - const task = async () => { - await wait(75) - return 'complete' - } - const results = await Promise.all([ - new Leader(717, db).run(task), - new Leader(717, db).run(task), - new Leader(717, db).run(task), - ]) - const byRan = (a, b) => Number(a.ran) - Number(b.ran) - expect(results.sort(byRan)).toEqual([ - { ran: false }, - { ran: false }, - { ran: true, result: 'complete' }, - ]) - }) - - it('leaders with different ids do not conflict.', async () => { - const task = async () => { - await wait(75) - return 'complete' - } - const results = await Promise.all([ - new Leader(727, db).run(task), - new Leader(728, db).run(task), - new Leader(729, db).run(task), - ]) - expect(results).toEqual([ - { ran: true, result: 'complete' }, - { ran: true, result: 'complete' }, - { ran: true, result: 'complete' }, - ]) - }) - - it('supports abort.', async () => { - const task = async (ctx: { signal: AbortSignal }) => { - wait(10).then(abort) - return await Promise.race([ - wait(50), - once(ctx.signal, 'abort').then(() => ctx.signal.reason), - ]) - } - const leader = new Leader(737, db) - const abort = () => { - leader.session?.abortController.abort(new Error('Oops!')) - } - const result = await leader.run(task) - expect(result).toEqual({ ran: true, result: new Error('Oops!') }) - }) - }) }) diff --git a/packages/bsky/tests/duplicate-records.test.ts b/packages/bsky/tests/data-plane/duplicate-records.test.ts similarity index 75% rename from packages/bsky/tests/duplicate-records.test.ts rename to packages/bsky/tests/data-plane/duplicate-records.test.ts index 9c7617bd668..da7287893ba 100644 --- a/packages/bsky/tests/duplicate-records.test.ts +++ b/packages/bsky/tests/data-plane/duplicate-records.test.ts @@ -2,23 +2,19 @@ import { AtUri } from '@atproto/syntax' import { cidForCbor, TID } from '@atproto/common' import { WriteOpAction } from '@atproto/repo' import { TestNetwork } from '@atproto/dev-env' -import { Database } from '../src' -import { PrimaryDatabase } from '../src/db' -import * as lex from '../src/lexicon/lexicons' -import { Services } from '../src/indexer/services' +import * as lex from '../../src/lexicon/lexicons' +import { Database } from '../../src' describe('duplicate record', () => { let network: TestNetwork let did: string - let db: PrimaryDatabase - let services: Services + let db: Database beforeAll(async () => { network = await TestNetwork.create({ dbPostgresSchema: 'bsky_duplicates', }) - db = network.bsky.indexer.ctx.db - services = network.bsky.indexer.ctx.services + db = network.bsky.db did = 'did:example:alice' }) @@ -51,21 +47,25 @@ describe('duplicate record', () => { } const uri = AtUri.make(did, coll, TID.nextStr()) const cid = await cidForCbor(repost) - await services - .indexing(db) - .indexRecord(uri, cid, repost, WriteOpAction.Create, repost.createdAt) + await network.bsky.sub.indexingSvc.indexRecord( + uri, + cid, + repost, + WriteOpAction.Create, + repost.createdAt, + ) uris.push(uri) } let count = await countRecords(db, 'repost') expect(count).toBe(1) - await services.indexing(db).deleteRecord(uris[0], false) + await network.bsky.sub.indexingSvc.deleteRecord(uris[0], false) count = await countRecords(db, 'repost') expect(count).toBe(1) - await services.indexing(db).deleteRecord(uris[1], true) + await network.bsky.sub.indexingSvc.deleteRecord(uris[1], true) count = await countRecords(db, 'repost') expect(count).toBe(0) @@ -87,16 +87,20 @@ describe('duplicate record', () => { } const uri = AtUri.make(did, coll, TID.nextStr()) const cid = await cidForCbor(like) - await services - .indexing(db) - .indexRecord(uri, cid, like, WriteOpAction.Create, like.createdAt) + await network.bsky.sub.indexingSvc.indexRecord( + uri, + cid, + like, + WriteOpAction.Create, + like.createdAt, + ) uris.push(uri) } let count = await countRecords(db, 'like') expect(count).toBe(1) - await services.indexing(db).deleteRecord(uris[0], false) + await network.bsky.sub.indexingSvc.deleteRecord(uris[0], false) count = await countRecords(db, 'like') expect(count).toBe(1) @@ -107,7 +111,7 @@ describe('duplicate record', () => { .executeTakeFirst() expect(got?.uri).toEqual(uris[1].toString()) - await services.indexing(db).deleteRecord(uris[1], true) + await network.bsky.sub.indexingSvc.deleteRecord(uris[1], true) count = await countRecords(db, 'like') expect(count).toBe(0) @@ -124,21 +128,25 @@ describe('duplicate record', () => { } const uri = AtUri.make(did, coll, TID.nextStr()) const cid = await cidForCbor(follow) - await services - .indexing(db) - .indexRecord(uri, cid, follow, WriteOpAction.Create, follow.createdAt) + await network.bsky.sub.indexingSvc.indexRecord( + uri, + cid, + follow, + WriteOpAction.Create, + follow.createdAt, + ) uris.push(uri) } let count = await countRecords(db, 'follow') expect(count).toBe(1) - await services.indexing(db).deleteRecord(uris[0], false) + await network.bsky.sub.indexingSvc.deleteRecord(uris[0], false) count = await countRecords(db, 'follow') expect(count).toBe(1) - await services.indexing(db).deleteRecord(uris[1], true) + await network.bsky.sub.indexingSvc.deleteRecord(uris[1], true) count = await countRecords(db, 'follow') expect(count).toBe(0) diff --git a/packages/bsky/tests/handle-invalidation.test.ts b/packages/bsky/tests/data-plane/handle-invalidation.test.ts similarity index 94% rename from packages/bsky/tests/handle-invalidation.test.ts rename to packages/bsky/tests/data-plane/handle-invalidation.test.ts index 70ac7c29a09..8469a8507ef 100644 --- a/packages/bsky/tests/handle-invalidation.test.ts +++ b/packages/bsky/tests/data-plane/handle-invalidation.test.ts @@ -25,8 +25,8 @@ describe('handle invalidation', () => { alice = sc.dids.alice bob = sc.dids.bob - const origResolve = network.bsky.indexer.ctx.idResolver.handle.resolve - network.bsky.indexer.ctx.idResolver.handle.resolve = async ( + const origResolve = network.bsky.dataplane.idResolver.handle.resolve + network.bsky.dataplane.idResolver.handle.resolve = async ( handle: string, ) => { if (mockHandles[handle] === null) { @@ -44,9 +44,8 @@ describe('handle invalidation', () => { const backdateIndexedAt = async (did: string) => { const TWO_DAYS_AGO = new Date(Date.now() - 2 * DAY).toISOString() - await network.bsky.ctx.db - .getPrimary() - .db.updateTable('actor') + await network.bsky.db.db + .updateTable('actor') .set({ indexedAt: TWO_DAYS_AGO }) .where('did', '=', did) .execute() diff --git a/packages/bsky/tests/indexing.test.ts b/packages/bsky/tests/data-plane/indexing.test.ts similarity index 84% rename from packages/bsky/tests/indexing.test.ts rename to packages/bsky/tests/data-plane/indexing.test.ts index 3a5a12b7ac6..f97495c6b32 100644 --- a/packages/bsky/tests/indexing.test.ts +++ b/packages/bsky/tests/data-plane/indexing.test.ts @@ -12,15 +12,16 @@ import AtpAgent, { AppBskyGraphFollow, } from '@atproto/api' import { TestNetwork, SeedClient, usersSeed, basicSeed } from '@atproto/dev-env' -import { forSnapshot } from './_util' -import { ids } from '../src/lexicon/lexicons' -import { Database } from '../src/db' +import { forSnapshot } from '../_util' +import { ids } from '../../src/lexicon/lexicons' +import { Database } from '../../src/data-plane/server/db' describe('indexing', () => { let network: TestNetwork let agent: AtpAgent let pdsAgent: AtpAgent let sc: SeedClient + let db: Database beforeAll(async () => { network = await TestNetwork.create({ @@ -29,12 +30,11 @@ describe('indexing', () => { agent = network.bsky.getClient() pdsAgent = network.pds.getClient() sc = network.getSeedClient() + db = network.bsky.db await usersSeed(sc) // Data in tests is not processed from subscription await network.processAll() - await network.bsky.ingester.sub.destroy() - await network.bsky.indexer.sub.destroy() - await network.bsky.processAll() + await network.bsky.sub.destroy() }) afterAll(async () => { @@ -42,7 +42,6 @@ describe('indexing', () => { }) it('indexes posts.', async () => { - const { db, services } = network.bsky.indexer.ctx const createdAt = new Date().toISOString() const createRecord = await prepareCreate({ did: sc.dids.alice, @@ -93,7 +92,7 @@ describe('indexing', () => { }) // Create - await services.indexing(db).indexRecord(...createRecord) + await network.bsky.sub.indexingSvc.indexRecord(...createRecord) const getAfterCreate = await agent.api.app.bsky.feed.getPostThread( { uri: uri.toString() }, @@ -103,7 +102,7 @@ describe('indexing', () => { const createNotifications = await getNotifications(db, uri) // Update - await services.indexing(db).indexRecord(...updateRecord) + await network.bsky.sub.indexingSvc.indexRecord(...updateRecord) const getAfterUpdate = await agent.api.app.bsky.feed.getPostThread( { uri: uri.toString() }, @@ -113,7 +112,7 @@ describe('indexing', () => { const updateNotifications = await getNotifications(db, uri) // Delete - await services.indexing(db).deleteRecord(...deleteRecord) + await network.bsky.sub.indexingSvc.deleteRecord(...deleteRecord) const getAfterDelete = agent.api.app.bsky.feed.getPostThread( { uri: uri.toString() }, @@ -132,7 +131,6 @@ describe('indexing', () => { }) it('indexes profiles.', async () => { - const { db, services } = network.bsky.indexer.ctx const createRecord = await prepareCreate({ did: sc.dids.dan, collection: ids.AppBskyActorProfile, @@ -159,7 +157,7 @@ describe('indexing', () => { }) // Create - await services.indexing(db).indexRecord(...createRecord) + await network.bsky.sub.indexingSvc.indexRecord(...createRecord) const getAfterCreate = await agent.api.app.bsky.actor.getProfile( { actor: sc.dids.dan }, @@ -168,7 +166,7 @@ describe('indexing', () => { expect(forSnapshot(getAfterCreate.data)).toMatchSnapshot() // Update - await services.indexing(db).indexRecord(...updateRecord) + await network.bsky.sub.indexingSvc.indexRecord(...updateRecord) const getAfterUpdate = await agent.api.app.bsky.actor.getProfile( { actor: sc.dids.dan }, @@ -177,7 +175,7 @@ describe('indexing', () => { expect(forSnapshot(getAfterUpdate.data)).toMatchSnapshot() // Delete - await services.indexing(db).deleteRecord(...deleteRecord) + await network.bsky.sub.indexingSvc.deleteRecord(...deleteRecord) const getAfterDelete = await agent.api.app.bsky.actor.getProfile( { actor: sc.dids.dan }, @@ -187,7 +185,6 @@ describe('indexing', () => { }) it('handles post aggregations out of order.', async () => { - const { db, services } = network.bsky.indexer.ctx const createdAt = new Date().toISOString() const originalPost = await prepareCreate({ did: sc.dids.alice, @@ -234,11 +231,11 @@ describe('indexing', () => { } as AppBskyFeedRepost.Record, }) // reply, like, and repost indexed orior to the original post - await services.indexing(db).indexRecord(...reply) - await services.indexing(db).indexRecord(...like) - await services.indexing(db).indexRecord(...repost) - await services.indexing(db).indexRecord(...originalPost) - await network.bsky.processAll() + await network.bsky.sub.indexingSvc.indexRecord(...reply) + await network.bsky.sub.indexingSvc.indexRecord(...like) + await network.bsky.sub.indexingSvc.indexRecord(...repost) + await network.bsky.sub.indexingSvc.indexRecord(...originalPost) + await network.bsky.sub.background.processAll() const agg = await db.db .selectFrom('post_agg') .selectAll() @@ -258,14 +255,13 @@ describe('indexing', () => { rkey: uri.rkey, }) } - await services.indexing(db).deleteRecord(...del(reply[0])) - await services.indexing(db).deleteRecord(...del(like[0])) - await services.indexing(db).deleteRecord(...del(repost[0])) - await services.indexing(db).deleteRecord(...del(originalPost[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(reply[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(like[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(repost[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(originalPost[0])) }) it('does not notify user of own like or repost', async () => { - const { db, services } = network.bsky.indexer.ctx const createdAt = new Date().toISOString() const originalPost = await prepareCreate({ @@ -323,13 +319,12 @@ describe('indexing', () => { } as AppBskyFeedRepost.Record, }) - await services.indexing(db).indexRecord(...originalPost) - await services.indexing(db).indexRecord(...ownLike) - await services.indexing(db).indexRecord(...ownRepost) - await services.indexing(db).indexRecord(...aliceLike) - await services.indexing(db).indexRecord(...aliceRepost) - - await network.bsky.processAll() + await network.bsky.sub.indexingSvc.indexRecord(...originalPost) + await network.bsky.sub.indexingSvc.indexRecord(...ownLike) + await network.bsky.sub.indexingSvc.indexRecord(...ownRepost) + await network.bsky.sub.indexingSvc.indexRecord(...aliceLike) + await network.bsky.sub.indexingSvc.indexRecord(...aliceRepost) + await network.bsky.sub.background.processAll() const { data: { notifications }, @@ -354,15 +349,14 @@ describe('indexing', () => { }) } - await services.indexing(db).deleteRecord(...del(ownLike[0])) - await services.indexing(db).deleteRecord(...del(ownRepost[0])) - await services.indexing(db).deleteRecord(...del(aliceLike[0])) - await services.indexing(db).deleteRecord(...del(aliceRepost[0])) - await services.indexing(db).deleteRecord(...del(originalPost[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(ownLike[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(ownRepost[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(aliceLike[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(aliceRepost[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(originalPost[0])) }) it('handles profile aggregations out of order.', async () => { - const { db, services } = network.bsky.indexer.ctx const createdAt = new Date().toISOString() const unknownDid = 'did:example:unknown' const follow = await prepareCreate({ @@ -374,8 +368,8 @@ describe('indexing', () => { createdAt, } as AppBskyGraphFollow.Record, }) - await services.indexing(db).indexRecord(...follow) - await network.bsky.processAll() + await network.bsky.sub.indexingSvc.indexRecord(...follow) + await network.bsky.sub.background.processAll() const agg = await db.db .selectFrom('profile_agg') .select(['did', 'followersCount']) @@ -393,22 +387,19 @@ describe('indexing', () => { rkey: uri.rkey, }) } - await services.indexing(db).deleteRecord(...del(follow[0])) + await network.bsky.sub.indexingSvc.deleteRecord(...del(follow[0])) }) describe('indexRepo', () => { beforeAll(async () => { - network.bsky.indexer.sub.resume() - network.bsky.ingester.sub.resume() + network.bsky.sub.run() await basicSeed(sc, false) await network.processAll() - await network.bsky.ingester.sub.destroy() - await network.bsky.indexer.sub.destroy() - await network.bsky.processAll() + await network.bsky.sub.destroy() + await network.bsky.sub.background.processAll() }) it('preserves indexes when no record changes.', async () => { - const { db, services } = network.bsky.indexer.ctx // Mark originals const { data: origProfile } = await agent.api.app.bsky.actor.getProfile( { actor: sc.dids.alice }, @@ -427,8 +418,8 @@ describe('indexing', () => { await pdsAgent.api.com.atproto.sync.getLatestCommit({ did: sc.dids.alice, }) - await services.indexing(db).indexRepo(sc.dids.alice, commit.cid) - await network.bsky.processAll() + await network.bsky.sub.indexingSvc.indexRepo(sc.dids.alice, commit.cid) + await network.bsky.sub.background.processAll() // Check const { data: profile } = await agent.api.app.bsky.actor.getProfile( { actor: sc.dids.alice }, @@ -448,7 +439,6 @@ describe('indexing', () => { }) it('updates indexes when records change.', async () => { - const { db, services } = network.bsky.indexer.ctx // Update profile await pdsAgent.api.com.atproto.repo.putRecord( { @@ -472,8 +462,8 @@ describe('indexing', () => { await pdsAgent.api.com.atproto.sync.getLatestCommit({ did: sc.dids.alice, }) - await services.indexing(db).indexRepo(sc.dids.alice, commit.cid) - await network.bsky.processAll() + await network.bsky.sub.indexingSvc.indexRepo(sc.dids.alice, commit.cid) + await network.bsky.sub.background.processAll() // Check const { data: profile } = await agent.api.app.bsky.actor.getProfile( { actor: sc.dids.alice }, @@ -495,7 +485,6 @@ describe('indexing', () => { }) it('skips invalid records.', async () => { - const { db, services } = network.bsky.indexer.ctx const { accountManager } = network.pds.ctx // const { db: pdsDb, services: pdsServices } = network.pds.ctx // Create a good and a bad post record @@ -531,7 +520,7 @@ describe('indexing', () => { await pdsAgent.api.com.atproto.sync.getLatestCommit({ did: sc.dids.alice, }) - await services.indexing(db).indexRepo(sc.dids.alice, commit.cid) + await network.bsky.sub.indexingSvc.indexRepo(sc.dids.alice, commit.cid) // Check const getGoodPost = agent.api.app.bsky.feed.getPostThread( { uri: writes[0].uri.toString(), depth: 0 }, @@ -556,7 +545,6 @@ describe('indexing', () => { } it('indexes handle for a fresh did', async () => { - const { db, services } = network.bsky.indexer.ctx const now = new Date().toISOString() const sessionAgent = new AtpAgent({ service: network.pds.url }) const { @@ -567,12 +555,11 @@ describe('indexing', () => { password: 'password', }) await expect(getIndexedHandle(did)).rejects.toThrow('Profile not found') - await services.indexing(db).indexHandle(did, now) + await network.bsky.sub.indexingSvc.indexHandle(did, now) await expect(getIndexedHandle(did)).resolves.toEqual('did1.test') }) it('reindexes handle for existing did when forced', async () => { - const { db, services } = network.bsky.indexer.ctx const now = new Date().toISOString() const sessionAgent = new AtpAgent({ service: network.pds.url }) const { @@ -582,19 +569,18 @@ describe('indexing', () => { handle: 'did2.test', password: 'password', }) - await services.indexing(db).indexHandle(did, now) + await network.bsky.sub.indexingSvc.indexHandle(did, now) await expect(getIndexedHandle(did)).resolves.toEqual('did2.test') await sessionAgent.com.atproto.identity.updateHandle({ handle: 'did2-updated.test', }) - await services.indexing(db).indexHandle(did, now) + await network.bsky.sub.indexingSvc.indexHandle(did, now) await expect(getIndexedHandle(did)).resolves.toEqual('did2.test') // Didn't update, not forced - await services.indexing(db).indexHandle(did, now, true) + await network.bsky.sub.indexingSvc.indexHandle(did, now, true) await expect(getIndexedHandle(did)).resolves.toEqual('did2-updated.test') }) it('handles profile aggregations out of order', async () => { - const { db, services } = network.bsky.indexer.ctx const now = new Date().toISOString() const sessionAgent = new AtpAgent({ service: network.pds.url }) const { @@ -613,9 +599,9 @@ describe('indexing', () => { createdAt: now, } as AppBskyGraphFollow.Record, }) - await services.indexing(db).indexRecord(...follow) - await services.indexing(db).indexHandle(did, now) - await network.bsky.processAll() + await network.bsky.sub.indexingSvc.indexRecord(...follow) + await network.bsky.sub.indexingSvc.indexHandle(did, now) + await network.bsky.sub.background.processAll() const agg = await db.db .selectFrom('profile_agg') .select(['did', 'followersCount']) @@ -630,13 +616,12 @@ describe('indexing', () => { describe('tombstoneActor', () => { it('does not unindex actor when they are still being hosted by their pds', async () => { - const { db, services } = network.bsky.indexer.ctx const { data: profileBefore } = await agent.api.app.bsky.actor.getProfile( { actor: sc.dids.alice }, { headers: await network.serviceHeaders(sc.dids.bob) }, ) // Attempt indexing tombstone - await services.indexing(db).tombstoneActor(sc.dids.alice) + await network.bsky.sub.indexingSvc.tombstoneActor(sc.dids.alice) const { data: profileAfter } = await agent.api.app.bsky.actor.getProfile( { actor: sc.dids.alice }, { headers: await network.serviceHeaders(sc.dids.bob) }, @@ -645,7 +630,6 @@ describe('indexing', () => { }) it('unindexes actor when they are no longer hosted by their pds', async () => { - const { db, services } = network.bsky.indexer.ctx const { alice } = sc.dids const getProfileBefore = agent.api.app.bsky.actor.getProfile( { actor: alice }, @@ -664,7 +648,7 @@ describe('indexing', () => { }) await network.pds.ctx.backgroundQueue.processAll() // Index tombstone - await services.indexing(db).tombstoneActor(alice) + await network.bsky.sub.indexingSvc.tombstoneActor(alice) const getProfileAfter = agent.api.app.bsky.actor.getProfile( { actor: alice }, { headers: await network.serviceHeaders(sc.dids.bob) }, diff --git a/packages/bsky/tests/subscription/repo.test.ts b/packages/bsky/tests/data-plane/subscription/repo.test.ts similarity index 83% rename from packages/bsky/tests/subscription/repo.test.ts rename to packages/bsky/tests/data-plane/subscription/repo.test.ts index fe910c85603..3a02b2fa61c 100644 --- a/packages/bsky/tests/subscription/repo.test.ts +++ b/packages/bsky/tests/data-plane/subscription/repo.test.ts @@ -4,14 +4,13 @@ import { CommitData } from '@atproto/repo' import { PreparedWrite } from '@atproto/pds/src/repo' import * as sequencer from '@atproto/pds/src/sequencer' import { cborDecode, cborEncode } from '@atproto/common' -import { DatabaseSchemaType } from '../../src/db/database-schema' -import { ids } from '../../src/lexicon/lexicons' -import { forSnapshot } from '../_util' -import { AppContext, Database } from '../../src' +import { DatabaseSchemaType } from '../../../src/data-plane/server/db/database-schema' +import { ids } from '../../../src/lexicon/lexicons' +import { forSnapshot } from '../../_util' +import { Database } from '../../../src' describe('sync', () => { let network: TestNetwork - let ctx: AppContext let pdsAgent: AtpAgent let sc: SeedClient @@ -19,7 +18,6 @@ describe('sync', () => { network = await TestNetwork.create({ dbPostgresSchema: 'bsky_subscription_repo', }) - ctx = network.bsky.ctx pdsAgent = network.pds.getClient() sc = network.getSeedClient() await basicSeed(sc) @@ -30,7 +28,7 @@ describe('sync', () => { }) it('indexes permit history being replayed.', async () => { - const db = ctx.db.getPrimary() + const { db } = network.bsky // Generate some modifications and dupes const { alice, bob, carol, dan } = sc.dids @@ -62,16 +60,12 @@ describe('sync', () => { const originalTableDump = await getTableDump() // Reprocess repos via sync subscription, on top of existing indices - await network.bsky.ingester.sub.destroy() - await network.bsky.indexer.sub.destroy() - // Hard reset of state in redis - await network.bsky.ingester.sub.resetCursor() - const indexerSub = network.bsky.indexer.sub - const partition = indexerSub.partitions.get(0) - await network.bsky.indexer.ctx.redis.del(partition.key) + await network.bsky.sub.destroy() + // Hard reset of state + network.bsky.sub.cursor = 0 + network.bsky.sub.seenSeq = null // Boot streams back up - network.bsky.indexer.sub.resume() - network.bsky.ingester.sub.resume() + network.bsky.sub.run() await network.processAll() // Permissive of indexedAt times changing @@ -102,7 +96,7 @@ describe('sync', () => { }) await network.processAll() // confirm jack was indexed as an actor despite the bad event - const actors = await dumpTable(ctx.db.getPrimary(), 'actor', ['did']) + const actors = await dumpTable(network.bsky.db, 'actor', ['did']) expect(actors.map((a) => a.handle)).toContain('jack.test') network.pds.ctx.sequencer.sequenceCommit = sequenceCommitOrig }) diff --git a/packages/bsky/tests/subscription/util.test.ts b/packages/bsky/tests/data-plane/subscription/util.test.ts similarity index 98% rename from packages/bsky/tests/subscription/util.test.ts rename to packages/bsky/tests/data-plane/subscription/util.test.ts index 497532f643b..0aba097c334 100644 --- a/packages/bsky/tests/subscription/util.test.ts +++ b/packages/bsky/tests/data-plane/subscription/util.test.ts @@ -3,8 +3,8 @@ import { ConsecutiveList, LatestQueue, PartitionedQueue, -} from '../../src/subscription/util' -import { randomStr } from '../../../crypto/src' +} from '../../../src/data-plane/server/subscription/util' +import { randomStr } from '../../../../crypto/src' describe('subscription utils', () => { describe('ConsecutiveList', () => { diff --git a/packages/bsky/tests/did-cache.test.ts b/packages/bsky/tests/did-cache.test.ts deleted file mode 100644 index 20114779fff..00000000000 --- a/packages/bsky/tests/did-cache.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { TestNetwork, SeedClient, usersSeed } from '@atproto/dev-env' -import { IdResolver } from '@atproto/identity' -import DidRedisCache from '../src/did-cache' -import { wait } from '@atproto/common' -import { Redis } from '../src' - -describe('did cache', () => { - let network: TestNetwork - let sc: SeedClient - let idResolver: IdResolver - let redis: Redis - let didCache: DidRedisCache - - let alice: string - let bob: string - let carol: string - let dan: string - - beforeAll(async () => { - network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_did_cache', - }) - idResolver = network.bsky.indexer.ctx.idResolver - redis = network.bsky.indexer.ctx.redis - didCache = network.bsky.indexer.ctx.didCache - sc = network.getSeedClient() - await usersSeed(sc) - await network.processAll() - alice = sc.dids.alice - bob = sc.dids.bob - carol = sc.dids.carol - dan = sc.dids.dan - }) - - afterAll(async () => { - await network.close() - }) - - it('caches dids on lookup', async () => { - await didCache.processAll() - const docs = await Promise.all([ - idResolver.did.cache?.checkCache(alice), - idResolver.did.cache?.checkCache(bob), - idResolver.did.cache?.checkCache(carol), - idResolver.did.cache?.checkCache(dan), - ]) - expect(docs.length).toBe(4) - expect(docs[0]?.doc.id).toEqual(alice) - expect(docs[1]?.doc.id).toEqual(bob) - expect(docs[2]?.doc.id).toEqual(carol) - expect(docs[3]?.doc.id).toEqual(dan) - }) - - it('clears cache and repopulates', async () => { - await Promise.all([ - idResolver.did.cache?.clearEntry(alice), - idResolver.did.cache?.clearEntry(bob), - idResolver.did.cache?.clearEntry(carol), - idResolver.did.cache?.clearEntry(dan), - ]) - const docsCleared = await Promise.all([ - idResolver.did.cache?.checkCache(alice), - idResolver.did.cache?.checkCache(bob), - idResolver.did.cache?.checkCache(carol), - idResolver.did.cache?.checkCache(dan), - ]) - expect(docsCleared).toEqual([null, null, null, null]) - - await Promise.all([ - idResolver.did.resolve(alice), - idResolver.did.resolve(bob), - idResolver.did.resolve(carol), - idResolver.did.resolve(dan), - ]) - await didCache.processAll() - - const docs = await Promise.all([ - idResolver.did.cache?.checkCache(alice), - idResolver.did.cache?.checkCache(bob), - idResolver.did.cache?.checkCache(carol), - idResolver.did.cache?.checkCache(dan), - ]) - expect(docs.length).toBe(4) - expect(docs[0]?.doc.id).toEqual(alice) - expect(docs[1]?.doc.id).toEqual(bob) - expect(docs[2]?.doc.id).toEqual(carol) - expect(docs[3]?.doc.id).toEqual(dan) - }) - - it('accurately reports expired dids & refreshes the cache', async () => { - const didCache = new DidRedisCache(redis.withNamespace('did-doc'), { - staleTTL: 1, - maxTTL: 60000, - }) - const shortCacheResolver = new IdResolver({ - plcUrl: network.bsky.ctx.cfg.didPlcUrl, - didCache, - }) - const doc = await shortCacheResolver.did.resolve(alice) - await didCache.processAll() - // let's mess with alice's doc so we know what we're getting - await didCache.cacheDid(alice, { ...doc, id: 'did:example:alice' }) - await wait(5) - - // first check the cache & see that we have the stale value - const cached = await shortCacheResolver.did.cache?.checkCache(alice) - expect(cached?.stale).toBe(true) - expect(cached?.doc.id).toEqual('did:example:alice') - // see that the resolver gives us the stale value while it revalidates - const staleGet = await shortCacheResolver.did.resolve(alice) - expect(staleGet?.id).toEqual('did:example:alice') - await didCache.processAll() - - // since it revalidated, ensure we have the new value - const updatedCache = await shortCacheResolver.did.cache?.checkCache(alice) - expect(updatedCache?.doc.id).toEqual(alice) - const updatedGet = await shortCacheResolver.did.resolve(alice) - expect(updatedGet?.id).toEqual(alice) - await didCache.destroy() - }) - - it('does not return expired dids & refreshes the cache', async () => { - const didCache = new DidRedisCache(redis.withNamespace('did-doc'), { - staleTTL: 0, - maxTTL: 1, - }) - const shortExpireResolver = new IdResolver({ - plcUrl: network.bsky.ctx.cfg.didPlcUrl, - didCache, - }) - const doc = await shortExpireResolver.did.resolve(alice) - await didCache.processAll() - - // again, we mess with the cached doc so we get something different - await didCache.cacheDid(alice, { ...doc, id: 'did:example:alice' }) - await wait(5) - - // see that the resolver does not return expired value & instead force refreshes - const staleGet = await shortExpireResolver.did.resolve(alice) - expect(staleGet?.id).toEqual(alice) - await didCache.destroy() - }) -}) diff --git a/packages/bsky/tests/feed-generation.test.ts b/packages/bsky/tests/feed-generation.test.ts index 5e63025ff7b..44f3760808b 100644 --- a/packages/bsky/tests/feed-generation.test.ts +++ b/packages/bsky/tests/feed-generation.test.ts @@ -1,3 +1,6 @@ +import assert from 'assert' +import { XRPCError } from '@atproto/xrpc' +import { AuthRequiredError } from '@atproto/xrpc-server' import { TID } from '@atproto/common' import { AtUri, AtpAgent } from '@atproto/api' import { @@ -16,9 +19,6 @@ import { SkeletonFeedPost, } from '../src/lexicon/types/app/bsky/feed/defs' import { forSnapshot, paginateAll } from './_util' -import { AuthRequiredError } from '@atproto/xrpc-server' -import assert from 'assert' -import { XRPCError } from '@atproto/xrpc' describe('feed generation', () => { let network: TestNetwork @@ -74,9 +74,8 @@ describe('feed generation', () => { { uri: feedUriBadPagination.toString(), order: 3 }, { uri: primeUri.toString(), order: 4 }, ] - await network.bsky.ctx.db - .getPrimary() - .db.insertInto('suggested_feed') + await network.bsky.db.db + .insertInto('suggested_feed') .values(feedSuggestions) .execute() }) @@ -151,23 +150,10 @@ describe('feed generation', () => { sc.getHeaders(alice), ) await network.processAll() - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: prime.uri, - cid: prime.cid, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownRecord({ + recordUri: prime.uri, + }) + feedUriAll = all.uri feedUriAllRef = new RecordRef(all.uri, all.cid) feedUriEven = even.uri @@ -319,7 +305,6 @@ describe('feed generation', () => { sc.getHeaders(sc.dids.bob), ) await network.processAll() - await network.bsky.processAll() // now take it offline await bobFg.close() @@ -359,15 +344,24 @@ describe('feed generation', () => { describe('getPopularFeedGenerators', () => { it('gets popular feed generators', async () => { - const resEven = - await agent.api.app.bsky.unspecced.getPopularFeedGenerators( - {}, - { headers: await network.serviceHeaders(sc.dids.bob) }, - ) - expect(resEven.data.feeds.map((f) => f.likeCount)).toEqual([ - 2, 0, 0, 0, 0, + const res = await agent.api.app.bsky.unspecced.getPopularFeedGenerators( + {}, + { headers: await network.serviceHeaders(sc.dids.bob) }, + ) + expect(res.data.feeds.map((f) => f.uri)).not.toContain(feedUriPrime) // taken-down + expect(res.data.feeds.map((f) => f.uri)).toEqual([ + feedUriAll, + feedUriEven, + feedUriBadPagination, ]) - expect(resEven.data.feeds.map((f) => f.uri)).not.toContain(feedUriPrime) // taken-down + }) + + it('searches feed generators', async () => { + const res = await agent.api.app.bsky.unspecced.getPopularFeedGenerators( + { query: 'all' }, + { headers: await network.serviceHeaders(sc.dids.bob) }, + ) + expect(res.data.feeds.map((f) => f.uri)).toEqual([feedUriAll]) }) it('paginates', async () => { @@ -376,7 +370,6 @@ describe('feed generation', () => { {}, { headers: await network.serviceHeaders(sc.dids.bob) }, ) - const resOne = await agent.api.app.bsky.unspecced.getPopularFeedGenerators( { limit: 2 }, diff --git a/packages/bsky/tests/image/server.test.ts b/packages/bsky/tests/image/server.test.ts index ee4d668945d..5ccd3dbd70a 100644 --- a/packages/bsky/tests/image/server.test.ts +++ b/packages/bsky/tests/image/server.test.ts @@ -18,7 +18,6 @@ describe('image processing server', () => { const sc = network.getSeedClient() await basicSeed(sc) await network.processAll() - await network.bsky.processAll() fileDid = sc.dids.carol fileCid = sc.posts[fileDid][0].images[0].image.ref client = axios.create({ diff --git a/packages/bsky/tests/image/uri.test.ts b/packages/bsky/tests/image/uri.test.ts index 60586d23f6b..137e2359706 100644 --- a/packages/bsky/tests/image/uri.test.ts +++ b/packages/bsky/tests/image/uri.test.ts @@ -14,16 +14,20 @@ describe('image uri builder', () => { }) it('generates paths.', () => { - expect(ImageUriBuilder.getPath({ preset: 'banner', did, cid })).toEqual( - `/banner/plain/${did}/${cid.toString()}@jpeg`, - ) expect( - ImageUriBuilder.getPath({ preset: 'feed_thumbnail', did, cid }), + ImageUriBuilder.getPath({ preset: 'banner', did, cid: cid.toString() }), + ).toEqual(`/banner/plain/${did}/${cid.toString()}@jpeg`) + expect( + ImageUriBuilder.getPath({ + preset: 'feed_thumbnail', + did, + cid: cid.toString(), + }), ).toEqual(`/feed_thumbnail/plain/${did}/${cid.toString()}@jpeg`) }) it('generates uris.', () => { - expect(uriBuilder.getPresetUri('banner', did, cid)).toEqual( + expect(uriBuilder.getPresetUri('banner', did, cid.toString())).toEqual( `https://example.com/img/banner/plain/${did}/${cid.toString()}@jpeg`, ) expect( @@ -38,7 +42,7 @@ describe('image uri builder', () => { ImageUriBuilder.getOptions(`/banner/plain/${did}/${cid.toString()}@png`), ).toEqual({ did: 'did:plc:xyz', - cid, + cid: cid.toString(), fit: 'cover', format: 'png', height: 1000, @@ -52,7 +56,7 @@ describe('image uri builder', () => { ), ).toEqual({ did: 'did:plc:xyz', - cid, + cid: cid.toString(), fit: 'inside', format: 'jpeg', height: 2000, diff --git a/packages/bsky/tests/notification-server.test.ts b/packages/bsky/tests/notification-server.test.ts deleted file mode 100644 index 0efd1e448b4..00000000000 --- a/packages/bsky/tests/notification-server.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import AtpAgent, { AtUri } from '@atproto/api' -import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' -import { - CourierNotificationServer, - GorushNotificationServer, -} from '../src/notifications' -import { Database } from '../src' -import { createCourierClient } from '../src/courier' - -describe('notification server', () => { - let network: TestNetwork - let agent: AtpAgent - let pdsAgent: AtpAgent - let sc: SeedClient - let notifServer: GorushNotificationServer - - // account dids, for convenience - let alice: string - - beforeAll(async () => { - network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_notification_server', - }) - agent = network.bsky.getClient() - pdsAgent = network.pds.getClient() - sc = network.getSeedClient() - await basicSeed(sc) - await network.processAll() - await network.bsky.processAll() - alice = sc.dids.alice - notifServer = new GorushNotificationServer( - network.bsky.ctx.db.getPrimary(), - 'http://mock', - ) - }) - - afterAll(async () => { - await network.close() - }) - - describe('registerPush', () => { - it('registers push notification token and device.', async () => { - const res = await agent.api.app.bsky.notification.registerPush( - { - serviceDid: network.bsky.ctx.cfg.serverDid, - platform: 'ios', - token: '123', - appId: 'xyz.blueskyweb.app', - }, - { - encoding: 'application/json', - headers: await network.serviceHeaders(alice), - }, - ) - expect(res.success).toEqual(true) - }) - - it('allows reregistering push notification token.', async () => { - const res1 = await agent.api.app.bsky.notification.registerPush( - { - serviceDid: network.bsky.ctx.cfg.serverDid, - platform: 'web', - token: '234', - appId: 'xyz.blueskyweb.app', - }, - { - encoding: 'application/json', - headers: await network.serviceHeaders(alice), - }, - ) - const res2 = await agent.api.app.bsky.notification.registerPush( - { - serviceDid: network.bsky.ctx.cfg.serverDid, - platform: 'web', - token: '234', - appId: 'xyz.blueskyweb.app', - }, - { - encoding: 'application/json', - headers: await network.serviceHeaders(alice), - }, - ) - expect(res1.success).toEqual(true) - expect(res2.success).toEqual(true) - }) - - it('does not allows registering push notification at mismatching service.', async () => { - const tryRegister = agent.api.app.bsky.notification.registerPush( - { - serviceDid: 'did:web:notifservice.com', - platform: 'ios', - token: '123', - appId: 'xyz.blueskyweb.app', - }, - { - encoding: 'application/json', - headers: await network.serviceHeaders(alice), - }, - ) - await expect(tryRegister).rejects.toThrow('Invalid serviceDid.') - }) - }) - - describe('NotificationServer', () => { - it('gets notification display attributes: title and body', async () => { - const db = network.bsky.ctx.db.getPrimary() - const notif = await getLikeNotification(db, alice) - if (!notif) throw new Error('no notification found') - const views = await notifServer.getNotificationViews([notif]) - if (!views.length) - throw new Error('no notification display attributes found') - expect(views[0].title).toEqual('bobby liked your post') - }) - - it('filters notifications that violate blocks', async () => { - const db = network.bsky.ctx.db.getPrimary() - const notif = await getLikeNotification(db, alice) - if (!notif) throw new Error('no notification found') - const blockRef = await pdsAgent.api.app.bsky.graph.block.create( - { repo: alice }, - { subject: notif.author, createdAt: new Date().toISOString() }, - sc.getHeaders(alice), - ) - await network.processAll() - // verify inverse of block - const flippedNotif = { - ...notif, - did: notif.author, - author: notif.did, - } - const views = await notifServer.getNotificationViews([ - notif, - flippedNotif, - ]) - expect(views.length).toBe(0) - const uri = new AtUri(blockRef.uri) - await pdsAgent.api.app.bsky.graph.block.delete( - { repo: alice, rkey: uri.rkey }, - sc.getHeaders(alice), - ) - await network.processAll() - }) - - it('filters notifications that violate mutes', async () => { - const db = network.bsky.ctx.db.getPrimary() - const notif = await getLikeNotification(db, alice) - if (!notif) throw new Error('no notification found') - await pdsAgent.api.app.bsky.graph.muteActor( - { actor: notif.author }, - { headers: sc.getHeaders(alice), encoding: 'application/json' }, - ) - const views = await notifServer.getNotificationViews([notif]) - expect(views.length).toBe(0) - await pdsAgent.api.app.bsky.graph.unmuteActor( - { actor: notif.author }, - { headers: sc.getHeaders(alice), encoding: 'application/json' }, - ) - }) - - it('filters notifications that violate mutelists', async () => { - const db = network.bsky.ctx.db.getPrimary() - const notif = await getLikeNotification(db, alice) - if (!notif) throw new Error('no notification found') - const listRef = await pdsAgent.api.app.bsky.graph.list.create( - { repo: alice }, - { - name: 'mute', - purpose: 'app.bsky.graph.defs#modlist', - createdAt: new Date().toISOString(), - }, - sc.getHeaders(alice), - ) - await pdsAgent.api.app.bsky.graph.listitem.create( - { repo: alice }, - { - subject: notif.author, - list: listRef.uri, - createdAt: new Date().toISOString(), - }, - sc.getHeaders(alice), - ) - await network.processAll() - await pdsAgent.api.app.bsky.graph.muteActorList( - { list: listRef.uri }, - { headers: sc.getHeaders(alice), encoding: 'application/json' }, - ) - const views = await notifServer.getNotificationViews([notif]) - expect(views.length).toBe(0) - await pdsAgent.api.app.bsky.graph.unmuteActorList( - { list: listRef.uri }, - { headers: sc.getHeaders(alice), encoding: 'application/json' }, - ) - }) - }) - - describe('GorushNotificationServer', () => { - it('gets user tokens from db', async () => { - const tokens = await notifServer.getTokensByDid([alice]) - expect(tokens[alice][0].token).toEqual('123') - }) - - it('prepares notification to be sent', async () => { - const db = network.bsky.ctx.db.getPrimary() - const notif = await getLikeNotification(db, alice) - if (!notif) throw new Error('no notification found') - const notifAsArray = [ - notif, - notif /* second one will get dropped by rate limit */, - ] - const prepared = await notifServer.prepareNotifications(notifAsArray) - expect(prepared).toEqual([ - { - collapse_id: 'like', - collapse_key: 'like', - data: { - reason: notif.reason, - recordCid: notif.recordCid, - recordUri: notif.recordUri, - }, - message: 'again', - platform: 1, - title: 'bobby liked your post', - tokens: ['123'], - topic: 'xyz.blueskyweb.app', - }, - ]) - }) - }) - - describe('CourierNotificationServer', () => { - it('prepares notification to be sent', async () => { - const db = network.bsky.ctx.db.getPrimary() - const notif = await getLikeNotification(db, alice) - if (!notif) throw new Error('no notification found') - const courierNotifServer = new CourierNotificationServer( - db, - createCourierClient({ baseUrl: 'http://mock', httpVersion: '2' }), - ) - const prepared = await courierNotifServer.prepareNotifications([notif]) - expect(prepared[0]?.id).toBeTruthy() - expect(prepared.map((p) => p.toJson())).toEqual([ - { - id: prepared[0].id, // already ensured it exists - recipientDid: notif.did, - title: 'bobby liked your post', - message: 'again', - collapseKey: 'like', - timestamp: notif.sortAt, - // this is missing, appears to be a quirk of toJson() - // alwaysDeliver: false, - additional: { - reason: notif.reason, - uri: notif.recordUri, - subject: notif.reasonSubject, - }, - }, - ]) - }) - }) - - async function getLikeNotification(db: Database, did: string) { - return await db.db - .selectFrom('notification') - .selectAll() - .where('did', '=', did) - .where('reason', '=', 'like') - .orderBy('sortAt') - .executeTakeFirst() - } -}) diff --git a/packages/bsky/tests/pipeline/backpressure.test.ts b/packages/bsky/tests/pipeline/backpressure.test.ts deleted file mode 100644 index 87e01b8cc89..00000000000 --- a/packages/bsky/tests/pipeline/backpressure.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { wait } from '@atproto/common' -import { - BskyIndexers, - TestNetworkNoAppView, - getIndexers, - getIngester, - processAll, - SeedClient, - basicSeed, -} from '@atproto/dev-env' -import { BskyIngester } from '../../src' - -const TEST_NAME = 'pipeline_backpressure' - -describe('pipeline backpressure', () => { - let network: TestNetworkNoAppView - let ingester: BskyIngester - let indexers: BskyIndexers - - let sc: SeedClient - - beforeAll(async () => { - network = await TestNetworkNoAppView.create({ - dbPostgresSchema: TEST_NAME, - }) - ingester = await getIngester(network, { - name: TEST_NAME, - ingesterPartitionCount: 2, - ingesterMaxItems: 10, - ingesterCheckItemsEveryN: 5, - }) - indexers = await getIndexers(network, { - name: TEST_NAME, - partitionIdsByIndexer: [[0], [1]], - }) - sc = network.getSeedClient() - await basicSeed(sc) - }) - - afterAll(async () => { - await network.close() - }) - - it('ingester issues backpressure based on total of partition lengths.', async () => { - // ingest until first 10 are seen - await ingester.start() - while ((ingester.sub.lastSeq ?? 0) < 10) { - await wait(50) - } - // allow additional time to pass to ensure no additional events are being consumed - await wait(200) - // check that max items has been respected (i.e. backpressure was applied) - const lengths = await ingester.ctx.redis.streamLengths(['repo:0', 'repo:1']) - expect(lengths).toHaveLength(2) - expect(lengths[0] + lengths[1]).toBeLessThanOrEqual(10 + 5) // not exact due to batching, may catch on following check backpressure - // drain all items using indexers, releasing backpressure - await indexers.start() - await processAll(network, ingester) - const lengthsFinal = await ingester.ctx.redis.streamLengths([ - 'repo:0', - 'repo:1', - ]) - expect(lengthsFinal).toHaveLength(2) - expect(lengthsFinal[0] + lengthsFinal[1]).toEqual(0) - await indexers.destroy() - await ingester.destroy() - }) -}) diff --git a/packages/bsky/tests/pipeline/reingest.test.ts b/packages/bsky/tests/pipeline/reingest.test.ts deleted file mode 100644 index 8d90f9fea8f..00000000000 --- a/packages/bsky/tests/pipeline/reingest.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - TestNetworkNoAppView, - SeedClient, - getIngester, - ingestAll, - basicSeed, -} from '@atproto/dev-env' -import { BskyIngester } from '../../src' - -const TEST_NAME = 'pipeline_reingest' - -describe('pipeline reingestion', () => { - let network: TestNetworkNoAppView - let ingester: BskyIngester - let sc: SeedClient - - beforeAll(async () => { - network = await TestNetworkNoAppView.create({ - dbPostgresSchema: TEST_NAME, - }) - ingester = await getIngester(network, { - name: TEST_NAME, - ingesterPartitionCount: 1, - }) - sc = network.getSeedClient() - await basicSeed(sc) - }) - - afterAll(async () => { - await network.close() - await ingester.destroy() - }) - - it('allows events to be reingested multiple times.', async () => { - // ingest all events once - await ingester.start() - await ingestAll(network, ingester) - const initialCursor = await ingester.sub.getCursor() - const [initialLen] = await ingester.ctx.redis.streamLengths(['repo:0']) - expect(initialCursor).toBeGreaterThan(10) - expect(initialLen).toBeGreaterThan(10) - // stop ingesting and reset ingester state - await ingester.sub.destroy() - await ingester.sub.resetCursor() - // add one new event and reingest - await sc.post(sc.dids.alice, 'one more event!') // add one event to firehose - ingester.sub.resume() - await ingestAll(network, ingester) - // confirm the newest event was ingested - const finalCursor = await ingester.sub.getCursor() - const [finalLen] = await ingester.ctx.redis.streamLengths(['repo:0']) - expect(finalCursor).toEqual(initialCursor + 1) - expect(finalLen).toEqual(initialLen + 1) - }) -}) diff --git a/packages/bsky/tests/pipeline/repartition.test.ts b/packages/bsky/tests/pipeline/repartition.test.ts deleted file mode 100644 index 2c7470fc06d..00000000000 --- a/packages/bsky/tests/pipeline/repartition.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - BskyIndexers, - TestNetworkNoAppView, - SeedClient, - getIndexers, - getIngester, - ingestAll, - processAll, - usersSeed, -} from '@atproto/dev-env' -import { BskyIngester } from '../../src' -import { countAll } from '../../src/db/util' - -const TEST_NAME = 'pipeline_repartition' - -describe('pipeline indexer repartitioning', () => { - let network: TestNetworkNoAppView - let ingester: BskyIngester - let indexers1: BskyIndexers - let indexers2: BskyIndexers - let sc: SeedClient - - beforeAll(async () => { - network = await TestNetworkNoAppView.create({ - dbPostgresSchema: TEST_NAME, - }) - ingester = await getIngester(network, { - name: TEST_NAME, - ingesterPartitionCount: 2, - }) - indexers1 = await getIndexers(network, { - name: TEST_NAME, - partitionIdsByIndexer: [[0, 1]], // one indexer consuming two partitions - }) - indexers2 = await getIndexers(network, { - name: TEST_NAME, - partitionIdsByIndexer: [[0], [1]], // two indexers, each consuming one partition - }) - sc = network.getSeedClient() - await usersSeed(sc) - }) - - afterAll(async () => { - await network.close() - }) - - it('indexers repartition without missing events.', async () => { - const poster = createPoster(sc) - await Promise.all([poster.post(4), indexers1.start(), ingester.start()]) - await poster.post(1) - await processAll(network, ingester) - const { count: indexedPosts } = await indexers1.db.db - .selectFrom('post') - .select(countAll.as('count')) - .executeTakeFirstOrThrow() - expect(indexedPosts).toEqual(5) - await Promise.all([poster.post(3), indexers1.destroy()]) - await poster.post(3) // miss some events - await ingestAll(network, ingester) - await Promise.all([poster.post(3), indexers2.start()]) // handle some events on indexers2 - await processAll(network, ingester) - const { count: allIndexedPosts } = await indexers2.db.db - .selectFrom('post') - .select(countAll.as('count')) - .executeTakeFirstOrThrow() - expect(allIndexedPosts).toBeGreaterThan(indexedPosts) - expect(allIndexedPosts).toEqual(poster.postCount) - await indexers2.destroy() - await ingester.destroy() - }) -}) - -function createPoster(sc: SeedClient) { - return { - postCount: 0, - destroyed: false, - async post(n = 1) { - const dids = Object.values(sc.dids) - for (let i = 0; i < n; ++i) { - const did = dids[this.postCount % dids.length] - await sc.post(did, `post ${this.postCount}`) - this.postCount++ - } - }, - } -} diff --git a/packages/bsky/tests/reprocessing.test.ts b/packages/bsky/tests/reprocessing.test.ts deleted file mode 100644 index fd9199379c7..00000000000 --- a/packages/bsky/tests/reprocessing.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import axios from 'axios' -import { AtUri } from '@atproto/syntax' -import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' -import { Database } from '../src/db' - -describe('reprocessing', () => { - let network: TestNetwork - let sc: SeedClient - let alice: string - - beforeAll(async () => { - network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_reprocessing', - }) - sc = network.getSeedClient() - await basicSeed(sc) - alice = sc.dids.alice - await network.processAll() - }) - - afterAll(async () => { - await network.close() - }) - - const getRecordUris = async (db: Database, did: string) => { - const res = await db.db - .selectFrom('record') - .select('uri') - .where('did', '=', did) - .execute() - return res.map((row) => row.uri) - } - it('reprocesses repo data', async () => { - const db = network.bsky.ctx.db.getPrimary() - const urisBefore = await getRecordUris(db, alice) - await db.db.deleteFrom('record').where('did', '=', alice).execute() - const indexerPort = network.bsky.indexer.ctx.cfg.indexerPort - await axios.post(`http://localhost:${indexerPort}/reprocess/${alice}`) - await network.processAll() - const urisAfter = await getRecordUris(db, alice) - expect(urisAfter.sort()).toEqual(urisBefore.sort()) - }) - - it('buffers commits while reprocessing repo data', async () => { - const db = network.bsky.ctx.db.getPrimary() - const urisBefore = await getRecordUris(db, alice) - await db.db.deleteFrom('record').where('did', '=', alice).execute() - const indexerPort = network.bsky.indexer.ctx.cfg.indexerPort - const toDeleteIndex = urisBefore.findIndex((uri) => - uri.includes('app.bsky.feed.post'), - ) - if (toDeleteIndex < 0) { - throw new Error('could not find post to delete') - } - // request reprocess while buffering a new post & delete - const [newPost] = await Promise.all([ - sc.post(alice, 'blah blah'), - axios.post(`http://localhost:${indexerPort}/reprocess/${alice}`), - sc.deletePost(alice, new AtUri(urisBefore[toDeleteIndex])), - ]) - await network.processAll() - const urisAfter = await getRecordUris(db, alice) - const expected = [ - ...urisBefore.slice(0, toDeleteIndex), - ...urisBefore.slice(toDeleteIndex + 1), - newPost.ref.uriStr, - ] - expect(urisAfter.sort()).toEqual(expected.sort()) - }) -}) diff --git a/packages/bsky/tests/server.test.ts b/packages/bsky/tests/server.test.ts index 157b352136f..3cc0257a4eb 100644 --- a/packages/bsky/tests/server.test.ts +++ b/packages/bsky/tests/server.test.ts @@ -3,11 +3,10 @@ import express from 'express' import axios, { AxiosError } from 'axios' import { TestNetwork, basicSeed } from '@atproto/dev-env' import { handler as errorHandler } from '../src/error' -import { Database } from '../src' +import { once } from 'events' describe('server', () => { let network: TestNetwork - let db: Database let alice: string beforeAll(async () => { @@ -18,7 +17,6 @@ describe('server', () => { await basicSeed(sc) await network.processAll() alice = sc.dids.alice - db = network.bsky.ctx.db.getPrimary() }) afterAll(async () => { @@ -56,7 +54,7 @@ describe('server', () => { it('healthcheck succeeds when database is available.', async () => { const { data, status } = await axios.get(`${network.bsky.url}/xrpc/_health`) expect(status).toEqual(200) - expect(data).toEqual({ version: '0.0.0' }) + expect(data).toEqual({ version: 'unknown' }) }) // TODO(bsky) check on a different endpoint that accepts json, currently none. @@ -107,10 +105,9 @@ describe('server', () => { expect(res.headers['content-encoding']).toBeUndefined() }) - it('healthcheck fails when database is unavailable.', async () => { - await network.bsky.ingester.sub.destroy() - await network.bsky.indexer.sub.destroy() - await db.close() + it('healthcheck fails when dataplane is unavailable.', async () => { + const { port } = network.bsky.dataplane.server.address() as AddressInfo + await network.bsky.dataplane.destroy() let error: AxiosError try { await axios.get(`${network.bsky.url}/xrpc/_health`) @@ -121,10 +118,14 @@ describe('server', () => { } else { throw err } + } finally { + // restart dataplane server to allow test suite to cleanup + network.bsky.dataplane.server.listen(port) + await once(network.bsky.dataplane.server, 'listening') } expect(error.response?.status).toEqual(503) expect(error.response?.data).toEqual({ - version: '0.0.0', + version: 'unknown', error: 'Service Unavailable', }) }) diff --git a/packages/bsky/tests/subscription/mutes.test.ts b/packages/bsky/tests/subscription/mutes.test.ts deleted file mode 100644 index 9b3f194050b..00000000000 --- a/packages/bsky/tests/subscription/mutes.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import AtpAgent from '@atproto/api' -import { wait } from '@atproto/common' -import { TestNetwork, SeedClient, basicSeed, TestBsync } from '@atproto/dev-env' -import assert from 'assert' - -describe('sync mutes', () => { - let network: TestNetwork - let bsync: TestBsync - let pdsAgent: AtpAgent - let sc: SeedClient - - beforeAll(async () => { - assert(process.env.DB_POSTGRES_URL) - bsync = await TestBsync.create({ - dbSchema: 'bsync_subscription_mutes', - dbUrl: process.env.DB_POSTGRES_URL, - }) - network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_subscription_mutes', - bsky: { - bsyncUrl: bsync.url, - bsyncApiKey: [...bsync.ctx.cfg.auth.apiKeys][0], - bsyncHttpVersion: '1.1', - bsyncOnlyMutes: true, - ingester: { - bsyncUrl: bsync.url, - bsyncApiKey: [...bsync.ctx.cfg.auth.apiKeys][0], - bsyncHttpVersion: '1.1', - }, - }, - }) - pdsAgent = network.pds.getClient() - sc = network.getSeedClient() - await basicSeed(sc) - }) - - afterAll(async () => { - await network.close() - await bsync.close() - }) - - it('mutes and unmutes actors.', async () => { - await pdsAgent.api.app.bsky.graph.muteActor( - { actor: sc.dids.alice }, - { headers: sc.getHeaders(sc.dids.bob), encoding: 'application/json' }, - ) - await pdsAgent.api.app.bsky.graph.muteActor( - { actor: sc.dids.carol }, - { headers: sc.getHeaders(sc.dids.bob), encoding: 'application/json' }, - ) - await pdsAgent.api.app.bsky.graph.muteActor( - { actor: sc.dids.dan }, - { headers: sc.getHeaders(sc.dids.bob), encoding: 'application/json' }, - ) - await processAllMuteOps(network, bsync) - const { data: mutes1 } = await pdsAgent.api.app.bsky.graph.getMutes( - {}, - { headers: sc.getHeaders(sc.dids.bob) }, - ) - expect(mutes1.mutes.map((mute) => mute.did)).toEqual([ - sc.dids.dan, - sc.dids.carol, - sc.dids.alice, - ]) - await pdsAgent.api.app.bsky.graph.unmuteActor( - { actor: sc.dids.carol }, - { headers: sc.getHeaders(sc.dids.bob), encoding: 'application/json' }, - ) - await processAllMuteOps(network, bsync) - const { data: mutes2 } = await pdsAgent.api.app.bsky.graph.getMutes( - {}, - { headers: sc.getHeaders(sc.dids.bob) }, - ) - expect(mutes2.mutes.map((mute) => mute.did)).toEqual([ - sc.dids.dan, - sc.dids.alice, - ]) - }) - - it('mutes and unmutes lists.', async () => { - // create lists - const list1 = await pdsAgent.api.app.bsky.graph.list.create( - { repo: sc.dids.bob }, - { - name: 'mod list 1', - purpose: 'app.bsky.graph.defs#modlist', - createdAt: new Date().toISOString(), - }, - sc.getHeaders(sc.dids.bob), - ) - const list2 = await pdsAgent.api.app.bsky.graph.list.create( - { repo: sc.dids.bob }, - { - name: 'mod list 2', - purpose: 'app.bsky.graph.defs#modlist', - createdAt: new Date().toISOString(), - }, - sc.getHeaders(sc.dids.bob), - ) - const list3 = await pdsAgent.api.app.bsky.graph.list.create( - { repo: sc.dids.bob }, - { - name: 'mod list 3', - purpose: 'app.bsky.graph.defs#modlist', - createdAt: new Date().toISOString(), - }, - sc.getHeaders(sc.dids.bob), - ) - await network.processAll() - await pdsAgent.api.app.bsky.graph.muteActorList( - { list: list1.uri }, - { headers: sc.getHeaders(sc.dids.bob), encoding: 'application/json' }, - ) - await pdsAgent.api.app.bsky.graph.muteActorList( - { list: list2.uri }, - { headers: sc.getHeaders(sc.dids.bob), encoding: 'application/json' }, - ) - await pdsAgent.api.app.bsky.graph.muteActorList( - { list: list3.uri }, - { headers: sc.getHeaders(sc.dids.bob), encoding: 'application/json' }, - ) - await processAllMuteOps(network, bsync) - const { data: listmutes1 } = await pdsAgent.api.app.bsky.graph.getListMutes( - {}, - { headers: sc.getHeaders(sc.dids.bob) }, - ) - expect(listmutes1.lists.map((list) => list.uri)).toEqual([ - list3.uri, - list2.uri, - list1.uri, - ]) - await pdsAgent.api.app.bsky.graph.unmuteActorList( - { list: list2.uri }, - { headers: sc.getHeaders(sc.dids.bob), encoding: 'application/json' }, - ) - await processAllMuteOps(network, bsync) - const { data: listmutes2 } = await pdsAgent.api.app.bsky.graph.getListMutes( - {}, - { headers: sc.getHeaders(sc.dids.bob) }, - ) - expect(listmutes2.lists.map((list) => list.uri)).toEqual([ - list3.uri, - list1.uri, - ]) - }) -}) - -async function processAllMuteOps(network: TestNetwork, bsync: TestBsync) { - const getBsyncCursor = async () => { - const result = await bsync.ctx.db.db - .selectFrom('mute_op') - .orderBy('id', 'desc') - .select('id') - .limit(1) - .executeTakeFirst() - return result?.id.toString() ?? null - } - assert(network.bsky.ingester.ctx.muteSubscription) - let total = 0 - while ( - (await getBsyncCursor()) !== - network.bsky.ingester.ctx.muteSubscription.cursor - ) { - if (total > 5000) { - throw new Error('timeout while processing mute ops') - } - await wait(50) - total += 50 - } -} diff --git a/packages/bsky/tests/views/__snapshots__/author-feed.test.ts.snap b/packages/bsky/tests/views/__snapshots__/author-feed.test.ts.snap index 37478713bd9..6112f126d97 100644 --- a/packages/bsky/tests/views/__snapshots__/author-feed.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/author-feed.test.ts.snap @@ -89,7 +89,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label", }, @@ -97,7 +97,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label-2", }, @@ -221,7 +221,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(5)", + "did": "user(4)", "handle": "dan.test", "labels": Array [], "viewer": Object { @@ -237,7 +237,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -332,7 +332,7 @@ Array [ "cid": "cids(6)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(6)", "val": "test-label", }, @@ -498,7 +498,7 @@ Array [ "cid": "cids(0)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(2)", + "src": "did:example:labeler", "uri": "record(0)", "val": "test-label", }, @@ -506,7 +506,7 @@ Array [ "cid": "cids(0)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(2)", + "src": "did:example:labeler", "uri": "record(0)", "val": "test-label-2", }, @@ -552,8 +552,8 @@ Array [ "parent": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", "displayName": "ali", "handle": "alice.test", "labels": Array [ @@ -561,7 +561,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "user(2)", "uri": "record(4)", "val": "self-label-a", }, @@ -569,7 +569,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "user(2)", "uri": "record(4)", "val": "self-label-b", }, @@ -600,8 +600,8 @@ Array [ "root": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", "displayName": "ali", "handle": "alice.test", "labels": Array [ @@ -609,7 +609,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "user(2)", "uri": "record(4)", "val": "self-label-a", }, @@ -617,7 +617,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "user(2)", "uri": "record(4)", "val": "self-label-b", }, @@ -746,13 +746,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(2)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(2)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(2)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(2)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(3)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(3)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(3)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(3)@jpeg", }, ], }, @@ -760,8 +760,8 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(5)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(5)@jpeg", + "did": "user(4)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -1037,13 +1037,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(2)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(2)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(2)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(2)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(3)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(3)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(3)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(3)@jpeg", }, ], }, @@ -1051,8 +1051,8 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(5)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(5)@jpeg", + "did": "user(4)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -1236,7 +1236,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(4)", "val": "test-label", }, @@ -1244,7 +1244,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(4)", "val": "test-label-2", }, @@ -1415,7 +1415,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -1432,13 +1432,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(5)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(8)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(8)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(8)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(8)@jpeg", }, ], }, @@ -1674,7 +1674,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label", }, @@ -1682,7 +1682,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label-2", }, @@ -1812,7 +1812,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(5)", + "did": "user(4)", "handle": "dan.test", "labels": Array [], "viewer": Object { @@ -1827,7 +1827,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -1920,7 +1920,7 @@ Array [ "cid": "cids(6)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(8)", "val": "test-label", }, diff --git a/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap index a3cfb905dc9..c5ff586f5c2 100644 --- a/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap @@ -103,7 +103,7 @@ Object { "cid": "cids(0)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(0)", "val": "test-label", }, @@ -288,7 +288,7 @@ Object { exports[`pds views with blocking from block lists returns a users own list blocks 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "lists": Array [ Object { "cid": "cids(0)", @@ -383,7 +383,7 @@ Object { exports[`pds views with blocking from block lists returns lists associated with a user 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "lists": Array [ Object { "cid": "cids(0)", @@ -477,7 +477,7 @@ Object { exports[`pds views with blocking from block lists returns the contents of a list 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "items": Array [ Object { "subject": Object { diff --git a/packages/bsky/tests/views/__snapshots__/blocks.test.ts.snap b/packages/bsky/tests/views/__snapshots__/blocks.test.ts.snap index d5ecf9b2c7e..751dc15b3ac 100644 --- a/packages/bsky/tests/views/__snapshots__/blocks.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/blocks.test.ts.snap @@ -103,7 +103,7 @@ Object { "cid": "cids(0)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(0)", "val": "test-label", }, @@ -301,7 +301,7 @@ Object { "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(7)", "val": "test-label", }, @@ -309,7 +309,7 @@ Object { "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(7)", "val": "test-label-2", }, diff --git a/packages/bsky/tests/views/__snapshots__/follows.test.ts.snap b/packages/bsky/tests/views/__snapshots__/follows.test.ts.snap index dfb5ff2ecb3..fcd5003475b 100644 --- a/packages/bsky/tests/views/__snapshots__/follows.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/follows.test.ts.snap @@ -2,7 +2,7 @@ exports[`pds follow views fetches followers 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "followers": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -83,7 +83,7 @@ Object { exports[`pds follow views fetches followers 2`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "followers": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -134,7 +134,7 @@ Object { exports[`pds follow views fetches followers 3`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "followers": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -200,7 +200,7 @@ Object { exports[`pds follow views fetches followers 4`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "followers": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -236,7 +236,7 @@ Object { exports[`pds follow views fetches followers 5`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "followers": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -287,7 +287,7 @@ Object { exports[`pds follow views fetches follows 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "follows": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -368,7 +368,7 @@ Object { exports[`pds follow views fetches follows 2`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "follows": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -419,7 +419,7 @@ Object { exports[`pds follow views fetches follows 3`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "follows": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -455,7 +455,7 @@ Object { exports[`pds follow views fetches follows 4`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "follows": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -521,7 +521,7 @@ Object { exports[`pds follow views fetches follows 5`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "follows": Array [ Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", @@ -589,11 +589,6 @@ Object { "did": "user(2)", "following": "record(2)", }, - Object { - "$type": "app.bsky.graph.defs#notFoundActor", - "actor": "did:example:fake", - "notFound": true, - }, ], } `; diff --git a/packages/bsky/tests/views/__snapshots__/likes.test.ts.snap b/packages/bsky/tests/views/__snapshots__/likes.test.ts.snap index 426467a3fa7..c9ad1536f85 100644 --- a/packages/bsky/tests/views/__snapshots__/likes.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/likes.test.ts.snap @@ -2,7 +2,7 @@ exports[`pds like views fetches post likes 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "likes": Array [ Object { "actor": Object { @@ -72,7 +72,7 @@ Object { exports[`pds like views fetches reply likes 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "likes": Array [ Object { "actor": Object { diff --git a/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap b/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap index 790cc5db4e6..f7887147f39 100644 --- a/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap @@ -90,7 +90,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label", }, @@ -98,7 +98,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label-2", }, @@ -221,7 +221,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label", }, @@ -229,7 +229,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label-2", }, @@ -408,7 +408,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(5)", + "did": "user(4)", "handle": "dan.test", "labels": Array [], "viewer": Object { @@ -423,7 +423,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -516,7 +516,7 @@ Array [ "cid": "cids(6)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(8)", "val": "test-label", }, diff --git a/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap b/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap index 8b46475eafe..4cd94ec2efc 100644 --- a/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap @@ -38,9 +38,11 @@ Object { "cid": "cids(4)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "description": "its me!", "did": "user(0)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", @@ -240,7 +242,7 @@ Object { "cid": "cids(5)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(9)", "val": "test-label", }, @@ -248,7 +250,7 @@ Object { "cid": "cids(5)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(9)", "val": "test-label-2", }, @@ -297,7 +299,7 @@ Object { exports[`bsky views with mutes from mute lists returns a users own list mutes 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "lists": Array [ Object { "cid": "cids(0)", @@ -390,7 +392,7 @@ Object { exports[`bsky views with mutes from mute lists returns lists associated with a user 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "lists": Array [ Object { "cid": "cids(0)", @@ -483,7 +485,7 @@ Object { exports[`bsky views with mutes from mute lists returns the contents of a list 1`] = ` Object { - "cursor": "0000000000000::bafycid", + "cursor": "0000000000000__bafycid", "items": Array [ Object { "subject": Object { diff --git a/packages/bsky/tests/views/__snapshots__/mutes.test.ts.snap b/packages/bsky/tests/views/__snapshots__/mutes.test.ts.snap index 655d7b62cb6..90919028294 100644 --- a/packages/bsky/tests/views/__snapshots__/mutes.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/mutes.test.ts.snap @@ -217,7 +217,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label", }, @@ -225,7 +225,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label-2", }, diff --git a/packages/bsky/tests/views/__snapshots__/notifications.test.ts.snap b/packages/bsky/tests/views/__snapshots__/notifications.test.ts.snap index e2ac2d587c0..9a3d1d088b8 100644 --- a/packages/bsky/tests/views/__snapshots__/notifications.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/notifications.test.ts.snap @@ -250,7 +250,7 @@ Array [ "cid": "cids(12)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(14)", "val": "test-label", }, @@ -258,7 +258,7 @@ Array [ "cid": "cids(12)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(14)", "val": "test-label-2", }, @@ -380,7 +380,7 @@ Array [ }, "cid": "cids(0)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "repost", "reasonSubject": "record(2)", @@ -407,7 +407,7 @@ Array [ }, "cid": "cids(2)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "repost", "reasonSubject": "record(4)", @@ -434,7 +434,7 @@ Array [ }, "cid": "cids(4)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "mention", "record": Object { @@ -478,7 +478,7 @@ Array [ }, "cid": "cids(6)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "like", "reasonSubject": "record(4)", @@ -506,7 +506,7 @@ Array [ }, "cid": "cids(7)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "follow", "record": Object { @@ -565,7 +565,7 @@ Array [ }, "cid": "cids(9)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "reply", "reasonSubject": "record(4)", @@ -600,7 +600,7 @@ Array [ }, "cid": "cids(10)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "like", "reasonSubject": "record(13)", @@ -628,7 +628,7 @@ Array [ }, "cid": "cids(12)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "like", "reasonSubject": "record(4)", @@ -660,7 +660,7 @@ Array [ }, "cid": "cids(13)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "follow", "record": Object { @@ -688,13 +688,13 @@ Array [ }, "cid": "cids(15)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [ Object { "cid": "cids(15)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(17)", "val": "test-label", }, @@ -702,7 +702,7 @@ Array [ "cid": "cids(15)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(17)", "val": "test-label-2", }, @@ -760,7 +760,7 @@ Array [ }, "cid": "cids(17)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "like", "reasonSubject": "record(13)", @@ -792,7 +792,7 @@ Array [ }, "cid": "cids(18)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "like", "reasonSubject": "record(4)", @@ -871,7 +871,7 @@ Array [ }, "cid": "cids(2)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [], "reason": "follow", "record": Object { @@ -915,13 +915,13 @@ Array [ }, "cid": "cids(5)", "indexedAt": "1970-01-01T00:00:00.000Z", - "isRead": false, + "isRead": true, "labels": Array [ Object { "cid": "cids(5)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(4)", "val": "test-label", }, diff --git a/packages/bsky/tests/views/__snapshots__/thread.test.ts.snap b/packages/bsky/tests/views/__snapshots__/thread.test.ts.snap index dd71dc9010d..41a448f63eb 100644 --- a/packages/bsky/tests/views/__snapshots__/thread.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/thread.test.ts.snap @@ -54,7 +54,6 @@ Object { "like": "record(7)", }, }, - "replies": Array [], }, "post": Object { "author": Object { @@ -85,7 +84,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label", }, @@ -93,7 +92,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label-2", }, @@ -135,7 +134,6 @@ Object { "uri": "record(5)", "viewer": Object {}, }, - "replies": Array [], }, "post": Object { "author": Object { @@ -319,7 +317,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(7)", "val": "test-label", }, @@ -327,7 +325,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(7)", "val": "test-label-2", }, @@ -557,7 +555,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(7)", "val": "test-label", }, @@ -565,7 +563,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(7)", "val": "test-label-2", }, @@ -1073,6 +1071,11 @@ Object { }, }, "replies": Array [ + Object { + "$type": "app.bsky.feed.defs#notFoundPost", + "notFound": true, + "uri": "record(5)", + }, Object { "$type": "app.bsky.feed.defs#threadViewPost", "post": Object { @@ -1104,16 +1107,16 @@ Object { "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", - "uri": "record(5)", + "src": "did:example:labeler", + "uri": "record(6)", "val": "test-label", }, Object { "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", - "uri": "record(5)", + "src": "did:example:labeler", + "uri": "record(6)", "val": "test-label-2", }, ], @@ -1151,7 +1154,7 @@ Object { }, "replyCount": 1, "repostCount": 0, - "uri": "record(5)", + "uri": "record(6)", "viewer": Object {}, }, "replies": Array [ @@ -1198,7 +1201,7 @@ Object { "reply": Object { "parent": Object { "cid": "cids(3)", - "uri": "record(5)", + "uri": "record(6)", }, "root": Object { "cid": "cids(0)", @@ -1209,9 +1212,9 @@ Object { }, "replyCount": 0, "repostCount": 2, - "uri": "record(6)", + "uri": "record(7)", "viewer": Object { - "repost": "record(7)", + "repost": "record(8)", }, }, "replies": Array [], @@ -1304,7 +1307,7 @@ Object { "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label", }, @@ -1312,7 +1315,7 @@ Object { "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label-2", }, diff --git a/packages/bsky/tests/views/__snapshots__/timeline.test.ts.snap b/packages/bsky/tests/views/__snapshots__/timeline.test.ts.snap index 0817313a331..32c41656733 100644 --- a/packages/bsky/tests/views/__snapshots__/timeline.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/timeline.test.ts.snap @@ -278,7 +278,7 @@ Array [ "cid": "cids(5)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(5)", "val": "test-label", }, @@ -853,7 +853,7 @@ Array [ "cid": "cids(6)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(8)", "val": "test-label", }, @@ -880,8 +880,8 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(1)@jpeg", - "did": "user(5)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", + "did": "user(4)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -1000,13 +1000,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(11)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(11)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(11)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(11)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(12)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(12)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(12)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(12)@jpeg", }, ], }, @@ -1014,8 +1014,8 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(1)@jpeg", - "did": "user(5)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", + "did": "user(4)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -1034,9 +1034,9 @@ Array [ "cid": "cids(13)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(15)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(15)", @@ -1058,9 +1058,9 @@ Array [ "cid": "cids(10)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(14)", - "val": "kind", + "val": "test-label-3", }, ], "likeCount": 2, @@ -1116,8 +1116,8 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(1)@jpeg", - "did": "user(5)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", + "did": "user(4)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -1135,9 +1135,9 @@ Array [ "cid": "cids(13)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", + "src": "did:example:labeler", "uri": "record(15)", - "val": "kind", + "val": "test-label-3", }, ], "likeCount": 0, @@ -1324,7 +1324,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label", }, @@ -1332,7 +1332,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label-2", }, @@ -1497,7 +1497,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -1516,13 +1516,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(5)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(8)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(8)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(8)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(8)@jpeg", }, ], }, @@ -1549,9 +1549,9 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(11)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(11)", @@ -1574,9 +1574,9 @@ Array [ "cid": "cids(7)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(8)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(8)", @@ -1660,7 +1660,7 @@ Array [ "reason": Object { "$type": "app.bsky.feed.defs#reasonRepost", "by": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -1760,7 +1760,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label", }, @@ -1768,7 +1768,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label-2", }, @@ -1859,7 +1859,7 @@ Array [ Object { "post": Object { "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2016,7 +2016,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label", }, @@ -2024,7 +2024,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(3)", "val": "test-label-2", }, @@ -2209,7 +2209,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2226,9 +2226,9 @@ Array [ "cid": "cids(7)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(8)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(8)", @@ -2313,7 +2313,7 @@ Array [ "cid": "cids(11)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(13)", "val": "test-label", }, @@ -2430,7 +2430,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2449,13 +2449,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(5)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(8)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(8)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(8)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(8)@jpeg", }, ], }, @@ -2482,9 +2482,9 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(11)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(11)", @@ -2507,9 +2507,9 @@ Array [ "cid": "cids(7)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(8)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(8)", @@ -2621,7 +2621,7 @@ Array [ Object { "post": Object { "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2639,13 +2639,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(5)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(8)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(8)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(8)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(8)@jpeg", }, ], }, @@ -2673,9 +2673,9 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(11)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(11)", @@ -2697,9 +2697,9 @@ Array [ "cid": "cids(7)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(8)", - "val": "kind", + "val": "test-label-3", }, ], "likeCount": 2, @@ -2774,9 +2774,9 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(11)", - "val": "kind", + "val": "test-label-3", }, ], "likeCount": 0, @@ -2898,13 +2898,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(2)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(2)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(2)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(2)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(3)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(3)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(3)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(3)@jpeg", }, ], }, @@ -2929,9 +2929,9 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(4)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(4)", @@ -2954,9 +2954,9 @@ Array [ "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(2)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(2)", @@ -3055,7 +3055,7 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -3139,7 +3139,7 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(10)", "val": "test-label", }, @@ -3147,7 +3147,7 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(10)", "val": "test-label-2", }, @@ -3192,7 +3192,7 @@ Array [ "root": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -3279,7 +3279,7 @@ Array [ "parent": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -3327,7 +3327,7 @@ Array [ "root": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -3404,7 +3404,7 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(10)", "val": "test-label", }, @@ -3412,7 +3412,7 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(10)", "val": "test-label-2", }, @@ -3458,7 +3458,7 @@ Array [ "parent": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -3506,7 +3506,7 @@ Array [ "root": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -3556,7 +3556,7 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -3623,9 +3623,9 @@ Array [ "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(2)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(2)", @@ -3710,7 +3710,7 @@ Array [ "cid": "cids(11)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(13)", "val": "test-label", }, @@ -3767,7 +3767,7 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -3833,13 +3833,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(2)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(2)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(2)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(2)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(3)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(3)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(3)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(3)@jpeg", }, ], }, @@ -3865,9 +3865,9 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(4)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(4)", @@ -3889,9 +3889,9 @@ Array [ "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(2)", - "val": "kind", + "val": "test-label-3", }, ], "likeCount": 2, @@ -3964,9 +3964,9 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(4)", - "val": "kind", + "val": "test-label-3", }, ], "likeCount": 0, @@ -3988,7 +3988,7 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -4088,13 +4088,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(2)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(2)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(2)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(2)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(3)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(3)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(3)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(3)@jpeg", }, ], }, @@ -4120,9 +4120,9 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(2)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(2)", @@ -4145,9 +4145,9 @@ Array [ "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(1)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(1)", @@ -4247,7 +4247,7 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -4332,7 +4332,7 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(10)", "val": "test-label", }, @@ -4340,7 +4340,7 @@ Array [ "cid": "cids(9)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(10)", "val": "test-label-2", }, @@ -4385,7 +4385,7 @@ Array [ "root": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -4471,7 +4471,7 @@ Array [ "parent": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -4519,7 +4519,7 @@ Array [ "root": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -4569,7 +4569,7 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -4634,9 +4634,9 @@ Array [ "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(1)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(1)", @@ -4721,7 +4721,7 @@ Array [ "cid": "cids(11)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(13)", "val": "test-label", }, @@ -4750,7 +4750,7 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -4815,13 +4815,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(2)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(2)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(2)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(2)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(3)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(3)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(3)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(3)@jpeg", }, ], }, @@ -4848,9 +4848,9 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(2)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(2)", @@ -4872,9 +4872,9 @@ Array [ "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "record(1)", - "val": "kind", + "val": "test-label-3", }, ], "likeCount": 2, @@ -4928,7 +4928,7 @@ Array [ Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(5)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(6)/cids(5)@jpeg", "did": "user(1)", "displayName": "ali", "handle": "alice.test", @@ -5096,7 +5096,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(4)", "val": "test-label", }, @@ -5104,7 +5104,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(4)", "val": "test-label-2", }, @@ -5289,7 +5289,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(4)", "val": "test-label", }, @@ -5297,7 +5297,7 @@ Array [ "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(4)", "val": "test-label-2", }, @@ -5484,7 +5484,7 @@ Array [ "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -5501,13 +5501,13 @@ Array [ "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(5)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(9)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(9)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(9)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(9)@jpeg", }, ], }, @@ -5533,9 +5533,9 @@ Array [ "cid": "cids(10)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(12)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(12)", @@ -5558,9 +5558,9 @@ Array [ "cid": "cids(8)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(11)", - "val": "kind", + "val": "test-label-3", }, ], "uri": "record(11)", @@ -5689,9 +5689,9 @@ Array [ "cid": "cids(10)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(5)", + "src": "did:example:labeler", "uri": "record(12)", - "val": "kind", + "val": "test-label-3", }, ], "likeCount": 0, diff --git a/packages/bsky/tests/views/actor-search.test.ts b/packages/bsky/tests/views/actor-search.test.ts index c0e862de249..e7c4390edc2 100644 --- a/packages/bsky/tests/views/actor-search.test.ts +++ b/packages/bsky/tests/views/actor-search.test.ts @@ -19,11 +19,11 @@ describe.skip('pds actor search views', () => { sc = network.getSeedClient() await wait(50) // allow pending sub to be established - await network.bsky.ingester.sub.destroy() + await network.bsky.sub.destroy() await usersBulkSeed(sc) // Skip did/handle resolution for expediency - const db = network.bsky.ctx.db.getPrimary() + const { db } = network.bsky const now = new Date().toISOString() await db.db .insertInto('actor') @@ -38,9 +38,8 @@ describe.skip('pds actor search views', () => { .execute() // Process remaining profiles - network.bsky.ingester.sub.resume() + network.bsky.sub.run() await network.processAll(50000) - await network.bsky.processAll() headers = await network.serviceHeaders(Object.values(sc.dids)[0]) }) @@ -238,22 +237,9 @@ describe.skip('pds actor search views', () => { }) it('search blocks by actor takedown', async () => { - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids['cara-wiegand69.test'], - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.server.ctx.dataplane.takedownActor({ + did: sc.dids['cara-wiegand69.test'], + }) const result = await agent.api.app.bsky.actor.searchActorsTypeahead( { term: 'car' }, { headers }, diff --git a/packages/bsky/tests/views/author-feed.test.ts b/packages/bsky/tests/views/author-feed.test.ts index b4644d49f67..160608744a6 100644 --- a/packages/bsky/tests/views/author-feed.test.ts +++ b/packages/bsky/tests/views/author-feed.test.ts @@ -146,22 +146,9 @@ describe('pds author feed views', () => { expect(preBlock.feed.length).toBeGreaterThan(0) - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownActor({ + did: alice, + }) const attemptAsUser = agent.api.app.bsky.feed.getAuthorFeed( { actor: alice }, @@ -173,25 +160,12 @@ describe('pds author feed views', () => { { actor: alice }, { headers: network.bsky.adminAuthHeaders() }, ) - expect(attemptAsAdmin.data.feed.length).toBeGreaterThan(0) expect(attemptAsAdmin.data.feed.length).toEqual(preBlock.feed.length) // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownActor({ + did: alice, + }) }) it('blocked by record takedown.', async () => { @@ -204,23 +178,9 @@ describe('pds author feed views', () => { const post = preBlock.feed[0].post - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: post.uri, - cid: post.cid, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownRecord({ + recordUri: post.uri, + }) const [{ data: postBlockAsUser }, { data: postBlockAsAdmin }] = await Promise.all([ @@ -244,22 +204,9 @@ describe('pds author feed views', () => { ) // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: post.uri, - cid: post.cid, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownRecord({ + recordUri: post.uri, + }) }) it('can filter by posts_with_media', async () => { diff --git a/packages/bsky/tests/views/blocks.test.ts b/packages/bsky/tests/views/blocks.test.ts index 2f45477c664..ceff6f57392 100644 --- a/packages/bsky/tests/views/blocks.test.ts +++ b/packages/bsky/tests/views/blocks.test.ts @@ -111,7 +111,7 @@ describe('pds views with blocking', () => { expect(forSnapshot(thread)).toMatchSnapshot() }) - it('loads blocked reply as anchor with no parent', async () => { + it('loads blocked reply as anchor with blocked parent', async () => { const { data: thread } = await agent.api.app.bsky.feed.getPostThread( { depth: 1, uri: carolReplyToDan.ref.uriStr }, { headers: await network.serviceHeaders(alice) }, @@ -120,7 +120,10 @@ describe('pds views with blocking', () => { throw new Error('Expected thread view post') } expect(thread.thread.post.uri).toEqual(carolReplyToDan.ref.uriStr) - expect(thread.thread.parent).toBeUndefined() + expect(thread.thread.parent).toMatchObject({ + $type: 'app.bsky.feed.defs#blockedPost', + uri: sc.posts[dan][0].ref.uriStr, + }) }) it('blocks thread parent', async () => { @@ -409,10 +412,9 @@ describe('pds views with blocking', () => { { headers: await network.serviceHeaders(alice) }, ) assert(isThreadViewPost(unblock.thread)) - expect(unblock.thread.replies?.map(getThreadPostUri)).toEqual([ - carolReplyToDan.ref.uriStr, - aliceReplyToDan.ref.uriStr, - ]) + expect(unblock.thread.replies?.map(getThreadPostUri).sort()).toEqual( + [aliceReplyToDan.ref.uriStr, carolReplyToDan.ref.uriStr].sort(), + ) // block then reply danBlockCarol = await pdsAgent.api.app.bsky.graph.block.create( diff --git a/packages/bsky/tests/views/follows.test.ts b/packages/bsky/tests/views/follows.test.ts index d0f640c6813..2331a0e990c 100644 --- a/packages/bsky/tests/views/follows.test.ts +++ b/packages/bsky/tests/views/follows.test.ts @@ -18,7 +18,6 @@ describe('pds follow views', () => { sc = network.getSeedClient() await followsSeed(sc) await network.processAll() - await network.bsky.processAll() alice = sc.dids.alice }) @@ -119,22 +118,9 @@ describe('pds follow views', () => { }) it('blocks followers by actor takedown', async () => { - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.dan, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownActor({ + did: sc.dids.dan, + }) const aliceFollowers = await agent.api.app.bsky.graph.getFollowers( { actor: sc.dids.alice }, @@ -145,21 +131,9 @@ describe('pds follow views', () => { sc.dids.dan, ) - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.dan, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownActor({ + did: sc.dids.dan, + }) }) it('fetches follows', async () => { @@ -252,22 +226,9 @@ describe('pds follow views', () => { }) it('blocks follows by actor takedown', async () => { - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.dan, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownActor({ + did: sc.dids.dan, + }) const aliceFollows = await agent.api.app.bsky.graph.getFollows( { actor: sc.dids.alice }, @@ -278,30 +239,18 @@ describe('pds follow views', () => { sc.dids.dan, ) - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.dan, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownActor({ + did: sc.dids.dan, + }) }) it('fetches relationships between users', async () => { const res = await agent.api.app.bsky.graph.getRelationships({ actor: sc.dids.bob, - others: [sc.dids.alice, sc.dids.bob, sc.dids.carol, 'did:example:fake'], + others: [sc.dids.alice, sc.dids.bob, sc.dids.carol], }) expect(res.data.actor).toEqual(sc.dids.bob) - expect(res.data.relationships.length).toBe(4) + expect(res.data.relationships.length).toBe(3) expect(forSnapshot(res.data)).toMatchSnapshot() }) }) diff --git a/packages/bsky/tests/views/list-feed.test.ts b/packages/bsky/tests/views/list-feed.test.ts index 4951a6d6a23..f7b68b8db7a 100644 --- a/packages/bsky/tests/views/list-feed.test.ts +++ b/packages/bsky/tests/views/list-feed.test.ts @@ -111,22 +111,9 @@ describe('list feed views', () => { }) it('blocks posts by actor takedown', async () => { - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: bob, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownActor({ + did: bob, + }) const res = await agent.api.app.bsky.feed.getListFeed({ list: listRef.uriStr, @@ -135,42 +122,16 @@ describe('list feed views', () => { expect(hasBob).toBe(false) // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: bob, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownActor({ + did: bob, + }) }) it('blocks posts by record takedown.', async () => { const postRef = sc.replies[bob][0].ref // Post and reply parent - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownRecord({ + recordUri: postRef.uriStr, + }) const res = await agent.api.app.bsky.feed.getListFeed({ list: listRef.uriStr, @@ -181,21 +142,8 @@ describe('list feed views', () => { expect(hasPost).toBe(false) // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownRecord({ + recordUri: postRef.uriStr, + }) }) }) diff --git a/packages/bsky/tests/views/mute-lists.test.ts b/packages/bsky/tests/views/mute-lists.test.ts index c366b9bf390..3f90586f681 100644 --- a/packages/bsky/tests/views/mute-lists.test.ts +++ b/packages/bsky/tests/views/mute-lists.test.ts @@ -92,7 +92,6 @@ describe('bsky views with mutes from mute lists', () => { }) it('uses a list for mutes', async () => { - // @TODO proxy through appview await agent.api.app.bsky.graph.muteActorList( { list: listUri, @@ -196,7 +195,6 @@ describe('bsky views with mutes from mute lists', () => { // unfollow so they _would_ show up in suggestions if not for mute await sc.unfollow(dan, carol) await network.processAll() - await network.bsky.processAll() const res = await agent.api.app.bsky.actor.getSuggestions( { diff --git a/packages/bsky/tests/views/mutes.test.ts b/packages/bsky/tests/views/mutes.test.ts index 8e26770ef23..b9d276b975e 100644 --- a/packages/bsky/tests/views/mutes.test.ts +++ b/packages/bsky/tests/views/mutes.test.ts @@ -228,6 +228,6 @@ describe('mute views', () => { encoding: 'application/json', }, ) - await expect(promise).rejects.toThrow('Cannot mute oneself') + await expect(promise).rejects.toThrow() // @TODO check error message w/ grpc error passthru }) }) diff --git a/packages/bsky/tests/views/notifications.test.ts b/packages/bsky/tests/views/notifications.test.ts index 376ead163fc..d9b88d0bffe 100644 --- a/packages/bsky/tests/views/notifications.test.ts +++ b/packages/bsky/tests/views/notifications.test.ts @@ -19,7 +19,6 @@ describe('notification views', () => { sc = network.getSeedClient() await basicSeed(sc) await network.processAll() - await network.bsky.processAll() alice = sc.dids.alice }) @@ -72,7 +71,6 @@ describe('notification views', () => { 'indeed', ) await network.processAll() - await network.bsky.processAll() const notifCountAlice = await agent.api.app.bsky.notification.getUnreadCount( @@ -96,7 +94,6 @@ describe('notification views', () => { await sc.deletePost(sc.dids.alice, root.ref.uri) const second = await sc.reply(sc.dids.carol, root.ref, first.ref, 'second') await network.processAll() - await network.bsky.processAll() const notifsAlice = await agent.api.app.bsky.notification.listNotifications( {}, @@ -132,7 +129,7 @@ describe('notification views', () => { expect(notifs.length).toBe(13) const readStates = notifs.map((notif) => notif.isRead) - expect(readStates).toEqual(notifs.map(() => false)) + expect(readStates).toEqual(notifs.map((_, i) => i !== 0)) // only first appears unread expect(forSnapshot(sort(notifs))).toMatchSnapshot() }) @@ -224,7 +221,7 @@ describe('notification views', () => { expect(notifs.length).toBe(13) const readStates = notifs.map((notif) => notif.isRead) - expect(readStates).toEqual(notifs.map((n) => n.indexedAt <= seenAt)) + expect(readStates).toEqual(notifs.map((n) => n.indexedAt < seenAt)) // reset last-seen await agent.api.app.bsky.notification.updateSeen( { seenAt: new Date(0).toISOString() }, @@ -240,23 +237,9 @@ describe('notification views', () => { const postRef2 = sc.posts[sc.dids.dan][1].ref // Mention await Promise.all( [postRef1, postRef2].map((postRef) => - agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ), + network.bsky.ctx.dataplane.takedownRecord({ + recordUri: postRef.uriStr, + }), ), ) @@ -277,22 +260,9 @@ describe('notification views', () => { // Cleanup await Promise.all( [postRef1, postRef2].map((postRef) => - agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ), + network.bsky.ctx.dataplane.untakedownRecord({ + recordUri: postRef.uriStr, + }), ), ) }) @@ -300,7 +270,7 @@ describe('notification views', () => { it('fails open on clearly bad cursor.', async () => { const { data: notifs } = await agent.api.app.bsky.notification.listNotifications( - { cursor: 'bad' }, + { cursor: '90210::bafycid' }, { headers: await network.serviceHeaders(alice) }, ) expect(notifs).toEqual({ notifications: [] }) diff --git a/packages/bsky/tests/views/profile.test.ts b/packages/bsky/tests/views/profile.test.ts index ddd484b3b31..8c83c3e49aa 100644 --- a/packages/bsky/tests/views/profile.test.ts +++ b/packages/bsky/tests/views/profile.test.ts @@ -183,93 +183,20 @@ describe('pds profile views', () => { }) it('blocked by actor takedown', async () => { - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - const promise = agent.api.app.bsky.actor.getProfile( - { actor: alice }, - { headers: await network.serviceHeaders(bob) }, - ) - - await expect(promise).rejects.toThrow('Account has been taken down') - - // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('blocked by actor suspension', async () => { - await pdsAgent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventTakedown', - durationInHours: 1, - }, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - await network.processAll() + await network.bsky.ctx.dataplane.takedownActor({ + did: alice, + }) const promise = agent.api.app.bsky.actor.getProfile( { actor: alice }, { headers: await network.serviceHeaders(bob) }, ) - await expect(promise).rejects.toThrow( - 'Account has been temporarily suspended', - ) + await expect(promise).rejects.toThrow('Account has been suspended') // Cleanup - await pdsAgent.api.com.atproto.admin.emitModerationEvent( - { - event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' }, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - await network.processAll() + await network.bsky.ctx.dataplane.untakedownActor({ + did: alice, + }) }) async function updateProfile(did: string, record: Record) { diff --git a/packages/bsky/tests/views/suggested-follows.test.ts b/packages/bsky/tests/views/suggested-follows.test.ts index ff9bec4539c..5d39f3f90ef 100644 --- a/packages/bsky/tests/views/suggested-follows.test.ts +++ b/packages/bsky/tests/views/suggested-follows.test.ts @@ -16,7 +16,6 @@ describe('suggested follows', () => { sc = network.getSeedClient() await likesSeed(sc) await network.processAll() - await network.bsky.processAll() const suggestions = [ { did: sc.dids.alice, order: 1 }, @@ -26,9 +25,8 @@ describe('suggested follows', () => { { did: sc.dids.fred, order: 5 }, { did: sc.dids.gina, order: 6 }, ] - await network.bsky.ctx.db - .getPrimary() - .db.insertInto('suggested_follow') + await network.bsky.db.db + .insertInto('suggested_follow') .values(suggestions) .execute() }) diff --git a/packages/bsky/tests/views/suggestions.test.ts b/packages/bsky/tests/views/suggestions.test.ts index bcae515ffb3..49bc5858a34 100644 --- a/packages/bsky/tests/views/suggestions.test.ts +++ b/packages/bsky/tests/views/suggestions.test.ts @@ -15,7 +15,6 @@ describe('pds user search views', () => { sc = network.getSeedClient() await basicSeed(sc) await network.processAll() - await network.bsky.processAll() const suggestions = [ { did: sc.dids.alice, order: 1 }, @@ -24,9 +23,8 @@ describe('pds user search views', () => { { did: sc.dids.dan, order: 4 }, ] - await network.bsky.ctx.db - .getPrimary() - .db.insertInto('suggested_follow') + await network.bsky.db.db + .insertInto('suggested_follow') .values(suggestions) .execute() }) @@ -61,21 +59,21 @@ describe('pds user search views', () => { it('paginates', async () => { const result1 = await agent.api.app.bsky.actor.getSuggestions( - { limit: 1 }, + { limit: 2 }, { headers: await network.serviceHeaders(sc.dids.carol) }, ) expect(result1.data.actors.length).toBe(1) expect(result1.data.actors[0].handle).toEqual('bob.test') const result2 = await agent.api.app.bsky.actor.getSuggestions( - { limit: 1, cursor: result1.data.cursor }, + { limit: 2, cursor: result1.data.cursor }, { headers: await network.serviceHeaders(sc.dids.carol) }, ) expect(result2.data.actors.length).toBe(1) expect(result2.data.actors[0].handle).toEqual('dan.test') const result3 = await agent.api.app.bsky.actor.getSuggestions( - { limit: 1, cursor: result2.data.cursor }, + { limit: 2, cursor: result2.data.cursor }, { headers: await network.serviceHeaders(sc.dids.carol) }, ) expect(result3.data.actors.length).toBe(0) @@ -110,9 +108,8 @@ describe('pds user search views', () => { subjectType: 'feed', }, ] - await network.bsky.ctx.db - .getPrimary() - .db.insertInto('tagged_suggestion') + await network.bsky.db.db + .insertInto('tagged_suggestion') .values(suggestions) .execute() const res = await agent.api.app.bsky.unspecced.getTaggedSuggestions() diff --git a/packages/bsky/tests/views/thread.test.ts b/packages/bsky/tests/views/thread.test.ts index 88f7db4c573..c3496f7cf50 100644 --- a/packages/bsky/tests/views/thread.test.ts +++ b/packages/bsky/tests/views/thread.test.ts @@ -113,7 +113,6 @@ describe('pds thread views', () => { ) indexes.aliceReplyReply = sc.replies[alice].length - 1 await network.processAll() - await network.bsky.processAll() const thread1 = await agent.api.app.bsky.feed.getPostThread( { uri: sc.posts[alice][indexes.aliceRoot].ref.uriStr }, @@ -123,7 +122,6 @@ describe('pds thread views', () => { await sc.deletePost(bob, sc.replies[bob][indexes.bobReply].ref.uri) await network.processAll() - await network.bsky.processAll() const thread2 = await agent.api.app.bsky.feed.getPostThread( { uri: sc.posts[alice][indexes.aliceRoot].ref.uriStr }, @@ -138,6 +136,56 @@ describe('pds thread views', () => { expect(forSnapshot(thread3.data.thread)).toMatchSnapshot() }) + it('omits parents and replies w/ different root than anchor post.', async () => { + const badRoot = sc.posts[alice][0] + const goodRoot = await sc.post(alice, 'good root') + const goodReply1 = await sc.reply( + alice, + goodRoot.ref, + goodRoot.ref, + 'good reply 1', + ) + const goodReply2 = await sc.reply( + alice, + goodRoot.ref, + goodReply1.ref, + 'good reply 2', + ) + const badReply = await sc.reply( + alice, + badRoot.ref, + goodReply1.ref, + 'bad reply', + ) + await network.processAll() + // good reply doesn't have replies w/ different root + const { data: goodReply1Thread } = + await agent.api.app.bsky.feed.getPostThread( + { uri: goodReply1.ref.uriStr }, + { headers: await network.serviceHeaders(alice) }, + ) + assert(isThreadViewPost(goodReply1Thread.thread)) + assert(isThreadViewPost(goodReply1Thread.thread.parent)) + expect(goodReply1Thread.thread.parent.post.uri).toEqual(goodRoot.ref.uriStr) + expect( + goodReply1Thread.thread.replies?.map((r) => { + assert(isThreadViewPost(r)) + return r.post.uri + }), + ).toEqual([ + goodReply2.ref.uriStr, // does not contain badReply + ]) + expect(goodReply1Thread.thread.parent.replies).toBeUndefined() + // bad reply doesn't have a parent, which would have a different root + const { data: badReplyThread } = + await agent.api.app.bsky.feed.getPostThread( + { uri: badReply.ref.uriStr }, + { headers: await network.serviceHeaders(alice) }, + ) + assert(isThreadViewPost(badReplyThread.thread)) + expect(badReplyThread.thread.parent).toBeUndefined() // is not goodReply1 + }) + it('reflects self-labels', async () => { const { data: thread } = await agent.api.app.bsky.feed.getPostThread( { uri: sc.posts[alice][0].ref.uriStr }, @@ -163,22 +211,9 @@ describe('pds thread views', () => { describe('takedown', () => { it('blocks post by actor', async () => { - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownActor({ + did: alice, + }) // Same as shallow post thread test, minus alice const promise = agent.api.app.bsky.feed.getPostThread( @@ -191,40 +226,15 @@ describe('pds thread views', () => { ) // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownActor({ + did: alice, + }) }) it('blocks replies by actor', async () => { - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: carol, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownActor({ + did: carol, + }) // Same as deep post thread test, minus carol const thread = await agent.api.app.bsky.feed.getPostThread( @@ -235,40 +245,15 @@ describe('pds thread views', () => { expect(forSnapshot(thread.data.thread)).toMatchSnapshot() // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: carol, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownActor({ + did: carol, + }) }) it('blocks ancestors by actor', async () => { - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: bob, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownActor({ + did: bob, + }) // Same as ancestor post thread test, minus bob const thread = await agent.api.app.bsky.feed.getPostThread( @@ -279,42 +264,16 @@ describe('pds thread views', () => { expect(forSnapshot(thread.data.thread)).toMatchSnapshot() // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: bob, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownActor({ + did: bob, + }) }) it('blocks post by record', async () => { const postRef = sc.posts[alice][1].ref - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownRecord({ + recordUri: postRef.uriStr, + }) const promise = agent.api.app.bsky.feed.getPostThread( { depth: 1, uri: postRef.uriStr }, @@ -326,22 +285,9 @@ describe('pds thread views', () => { ) // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownRecord({ + recordUri: postRef.uriStr, + }) }) it('blocks ancestors by record', async () => { @@ -352,23 +298,9 @@ describe('pds thread views', () => { const parent = threadPreTakedown.data.thread.parent?.['post'] - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: parent.uri, - cid: parent.cid, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.takedownRecord({ + recordUri: parent.uri, + }) // Same as ancestor post thread test, minus parent post const thread = await agent.api.app.bsky.feed.getPostThread( @@ -379,22 +311,9 @@ describe('pds thread views', () => { expect(forSnapshot(thread.data.thread)).toMatchSnapshot() // Cleanup - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: parent.uri, - cid: parent.cid, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) + await network.bsky.ctx.dataplane.untakedownRecord({ + recordUri: parent.uri, + }) }) it('blocks replies by record', async () => { @@ -407,23 +326,9 @@ describe('pds thread views', () => { await Promise.all( [post1, post2].map((post) => - agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: post.uri, - cid: post.cid, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ), + network.bsky.ctx.dataplane.takedownRecord({ + recordUri: post.uri, + }), ), ) @@ -438,25 +343,9 @@ describe('pds thread views', () => { // Cleanup await Promise.all( [post1, post2].map((post) => - agent.api.com.atproto.admin.updateSubjectStatus( - { - event: { - $type: 'com.atproto.admin.defs#modEventReverseTakedown', - }, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: post.uri, - cid: post.cid, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ), + network.bsky.ctx.dataplane.untakedownRecord({ + recordUri: post.uri, + }), ), ) }) diff --git a/packages/bsky/tests/views/threadgating.test.ts b/packages/bsky/tests/views/threadgating.test.ts index feb10dfbadd..cd154cedd83 100644 --- a/packages/bsky/tests/views/threadgating.test.ts +++ b/packages/bsky/tests/views/threadgating.test.ts @@ -308,8 +308,9 @@ describe('views with thread gating', () => { assert(isThreadViewPost(reply1)) assert(isThreadViewPost(reply2)) expect(otherReplies.length).toEqual(0) - expect(reply1.post.uri).toEqual(danReply.ref.uriStr) - expect(reply2.post.uri).toEqual(aliceReply.ref.uriStr) + expect([reply1.post.uri, reply2.post.uri].sort()).toEqual( + [danReply.ref.uriStr, aliceReply.ref.uriStr].sort(), + ) }) it('applies gate for unknown list rule.', async () => { @@ -418,8 +419,9 @@ describe('views with thread gating', () => { assert(isThreadViewPost(reply1)) assert(isThreadViewPost(reply2)) expect(otherReplies.length).toEqual(0) - expect(reply1.post.uri).toEqual(danReply.ref.uriStr) - expect(reply2.post.uri).toEqual(aliceReply.ref.uriStr) + expect([reply1.post.uri, reply2.post.uri].sort()).toEqual( + [aliceReply.ref.uriStr, danReply.ref.uriStr].sort(), + ) }) it('applies gate for missing rules, takes no action.', async () => { diff --git a/packages/bsky/tests/views/timeline.test.ts b/packages/bsky/tests/views/timeline.test.ts index dd9b89535c8..f697f02e033 100644 --- a/packages/bsky/tests/views/timeline.test.ts +++ b/packages/bsky/tests/views/timeline.test.ts @@ -2,8 +2,10 @@ import assert from 'assert' import AtpAgent from '@atproto/api' import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' import { forSnapshot, getOriginator, paginateAll } from '../_util' -import { FeedAlgorithm } from '../../src/api/app/bsky/util/feed' import { FeedViewPost } from '../../src/lexicon/types/app/bsky/feed/defs' +import { Database } from '../../src' + +const REVERSE_CHRON = 'reverse-chronological' describe('timeline views', () => { let network: TestNetwork @@ -28,26 +30,18 @@ describe('timeline views', () => { bob = sc.dids.bob carol = sc.dids.carol dan = sc.dids.dan - // Label posts as "kind" to check labels on embed views - const labelPostA = sc.posts[bob][0].ref - const labelPostB = sc.posts[carol][0].ref - await network.bsky.ctx.services - .label(network.bsky.ctx.db.getPrimary()) - .formatAndCreate( - network.ozone.ctx.cfg.service.did, - labelPostA.uriStr, - labelPostA.cidStr, - { create: ['kind'] }, - ) - await network.bsky.ctx.services - .label(network.bsky.ctx.db.getPrimary()) - .formatAndCreate( - network.ozone.ctx.cfg.service.did, - labelPostB.uriStr, - labelPostB.cidStr, - { create: ['kind'] }, - ) - await network.bsky.processAll() + // covers label hydration on embeds + const { db } = network.bsky + await createLabel(db, { + val: 'test-label-3', + uri: sc.posts[bob][0].ref.uriStr, + cid: sc.posts[bob][0].ref.cidStr, + }) + await createLabel(db, { + val: 'test-label-3', + uri: sc.posts[carol][0].ref.uriStr, + cid: sc.posts[carol][0].ref.cidStr, + }) }) afterAll(async () => { @@ -67,7 +61,7 @@ describe('timeline views', () => { } const aliceTL = await agent.api.app.bsky.feed.getTimeline( - { algorithm: FeedAlgorithm.ReverseChronological }, + { algorithm: REVERSE_CHRON }, { headers: await network.serviceHeaders(alice), }, @@ -77,7 +71,7 @@ describe('timeline views', () => { aliceTL.data.feed.forEach(expectOriginatorFollowedBy(alice)) const bobTL = await agent.api.app.bsky.feed.getTimeline( - { algorithm: FeedAlgorithm.ReverseChronological }, + { algorithm: REVERSE_CHRON }, { headers: await network.serviceHeaders(bob), }, @@ -87,7 +81,7 @@ describe('timeline views', () => { bobTL.data.feed.forEach(expectOriginatorFollowedBy(bob)) const carolTL = await agent.api.app.bsky.feed.getTimeline( - { algorithm: FeedAlgorithm.ReverseChronological }, + { algorithm: REVERSE_CHRON }, { headers: await network.serviceHeaders(carol), }, @@ -97,7 +91,7 @@ describe('timeline views', () => { carolTL.data.feed.forEach(expectOriginatorFollowedBy(carol)) const danTL = await agent.api.app.bsky.feed.getTimeline( - { algorithm: FeedAlgorithm.ReverseChronological }, + { algorithm: REVERSE_CHRON }, { headers: await network.serviceHeaders(dan), }, @@ -115,7 +109,7 @@ describe('timeline views', () => { }, ) const reverseChronologicalTL = await agent.api.app.bsky.feed.getTimeline( - { algorithm: FeedAlgorithm.ReverseChronological }, + { algorithm: REVERSE_CHRON }, { headers: await network.serviceHeaders(alice), }, @@ -128,7 +122,7 @@ describe('timeline views', () => { const paginator = async (cursor?: string) => { const res = await agent.api.app.bsky.feed.getTimeline( { - algorithm: FeedAlgorithm.ReverseChronological, + algorithm: REVERSE_CHRON, cursor, limit: 4, }, @@ -144,7 +138,7 @@ describe('timeline views', () => { const full = await agent.api.app.bsky.feed.getTimeline( { - algorithm: FeedAlgorithm.ReverseChronological, + algorithm: REVERSE_CHRON, }, { headers: await network.serviceHeaders(carol) }, ) @@ -196,27 +190,12 @@ describe('timeline views', () => { it('blocks posts, reposts, replies by actor takedown', async () => { await Promise.all( [bob, carol].map((did) => - agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ), + network.bsky.ctx.dataplane.takedownActor({ did }), ), ) const aliceTL = await agent.api.app.bsky.feed.getTimeline( - { algorithm: FeedAlgorithm.ReverseChronological }, + { algorithm: REVERSE_CHRON }, { headers: await network.serviceHeaders(alice) }, ) @@ -225,21 +204,7 @@ describe('timeline views', () => { // Cleanup await Promise.all( [bob, carol].map((did) => - agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ), + network.bsky.ctx.dataplane.untakedownActor({ did }), ), ) }) @@ -249,28 +214,14 @@ describe('timeline views', () => { const postRef2 = sc.replies[bob][0].ref // Post and reply parent await Promise.all( [postRef1, postRef2].map((postRef) => - agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - takedown: { - applied: true, - ref: 'test', - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ), + network.bsky.ctx.dataplane.takedownRecord({ + recordUri: postRef.uriStr, + }), ), ) const aliceTL = await agent.api.app.bsky.feed.getTimeline( - { algorithm: FeedAlgorithm.ReverseChronological }, + { algorithm: REVERSE_CHRON }, { headers: await network.serviceHeaders(alice) }, ) @@ -279,31 +230,35 @@ describe('timeline views', () => { // Cleanup await Promise.all( [postRef1, postRef2].map((postRef) => - agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - takedown: { - applied: false, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ), + network.bsky.ctx.dataplane.untakedownRecord({ + recordUri: postRef.uriStr, + }), ), ) }) it('fails open on clearly bad cursor.', async () => { const { data: timeline } = await agent.api.app.bsky.feed.getTimeline( - { cursor: 'bad' }, + { cursor: '90210::bafycid' }, { headers: await network.serviceHeaders(alice) }, ) expect(timeline).toEqual({ feed: [] }) }) }) + +const createLabel = async ( + db: Database, + opts: { uri: string; cid: string; val: string }, +) => { + await db.db + .insertInto('label') + .values({ + uri: opts.uri, + cid: opts.cid, + val: opts.val, + cts: new Date().toISOString(), + neg: false, + src: 'did:example:labeler', + }) + .execute() +} diff --git a/packages/bsky/tsconfig.json b/packages/bsky/tsconfig.json index 3f6ca1c27ec..45283e8f73b 100644 --- a/packages/bsky/tsconfig.json +++ b/packages/bsky/tsconfig.json @@ -11,11 +11,10 @@ { "path": "../api/tsconfig.build.json" }, { "path": "../common/tsconfig.build.json" }, { "path": "../crypto/tsconfig.build.json" }, - { "path": "../identifier/tsconfig.build.json" }, { "path": "../lexicon/tsconfig.build.json" }, { "path": "../lex-cli/tsconfig.build.json" }, { "path": "../repo/tsconfig.build.json" }, - { "path": "../uri/tsconfig.build.json" }, + { "path": "../syntax/tsconfig.build.json" }, { "path": "../xrpc-server/tsconfig.build.json" } ] } diff --git a/packages/common-web/src/arrays.ts b/packages/common-web/src/arrays.ts index 51598fc86f1..36c2f0dcb27 100644 --- a/packages/common-web/src/arrays.ts +++ b/packages/common-web/src/arrays.ts @@ -1,3 +1,10 @@ +export const keyBy = (arr: T[], key: string): Record => { + return arr.reduce((acc, cur) => { + acc[cur[key]] = cur + return acc + }, {} as Record) +} + export const mapDefined = ( arr: T[], fn: (obj: T) => S | undefined, diff --git a/packages/dev-env/src/bsky.ts b/packages/dev-env/src/bsky.ts index 4548cd45c41..461ef4d07df 100644 --- a/packages/dev-env/src/bsky.ts +++ b/packages/dev-env/src/bsky.ts @@ -1,23 +1,22 @@ -import assert from 'assert' import getPort from 'get-port' import * as ui8 from 'uint8arrays' import * as bsky from '@atproto/bsky' -import { DAY, HOUR, MINUTE, SECOND, wait } from '@atproto/common-web' import { AtpAgent } from '@atproto/api' -import { Secp256k1Keypair, randomIntFromSeed } from '@atproto/crypto' +import { Secp256k1Keypair } from '@atproto/crypto' import { Client as PlcClient } from '@did-plc/lib' import { BskyConfig } from './types' -import { uniqueLockId } from './util' -import { TestNetworkNoAppView } from './network-no-appview' import { ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD } from './const' +import { BackgroundQueue } from '@atproto/bsky/src/data-plane/server/background' export class TestBsky { constructor( public url: string, public port: number, + public db: bsky.Database, public server: bsky.BskyAppView, - public indexer: bsky.BskyIndexer, - public ingester: bsky.BskyIngester, + public dataplane: bsky.DataPlaneServer, + public bsync: bsky.MockBsync, + public sub: bsky.RepoSubscription, ) {} static async create(cfg: BskyConfig): Promise { @@ -34,39 +33,43 @@ export class TestBsky { signer: serviceKeypair, }) + // shared across server, ingester, and indexer in order to share pool, avoid too many pg connections. + const db = new bsky.Database({ + url: cfg.dbPostgresUrl, + schema: cfg.dbPostgresSchema, + poolSize: 10, + }) + + const dataplanePort = await getPort() + const dataplane = await bsky.DataPlaneServer.create( + db, + dataplanePort, + cfg.plcUrl, + ) + + const bsyncPort = await getPort() + const bsync = await bsky.MockBsync.create(db, bsyncPort) + const config = new bsky.ServerConfig({ - version: '0.0.0', + version: 'unknown', port, didPlcUrl: cfg.plcUrl, publicUrl: 'https://bsky.public.url', serverDid, - didCacheStaleTTL: HOUR, - didCacheMaxTTL: DAY, - labelCacheStaleTTL: 30 * SECOND, - labelCacheMaxTTL: MINUTE, + dataplaneUrls: [`http://localhost:${dataplanePort}`], + dataplaneHttpVersion: '1.1', + bsyncUrl: `http://localhost:${bsyncPort}`, + bsyncHttpVersion: '1.1', + courierUrl: 'https://fake.example', modServiceDid: cfg.modServiceDid ?? 'did:example:invalidMod', + labelsFromIssuerDids: ['did:example:labeler'], // this did is also used as the labeler in seeds ...cfg, - // Each test suite gets its own lock id for the repo subscription - adminPassword: ADMIN_PASSWORD, - moderatorPassword: MOD_PASSWORD, - triagePassword: TRIAGE_PASSWORD, - feedGenDid: 'did:example:feedGen', - rateLimitsEnabled: false, - }) - - // shared across server, ingester, and indexer in order to share pool, avoid too many pg connections. - const db = new bsky.DatabaseCoordinator({ - schema: cfg.dbPostgresSchema, - primary: { - url: cfg.dbPrimaryPostgresUrl, - poolSize: 10, - }, - replicas: [], + adminPasswords: [ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD], }) // Separate migration db in case migration changes some connection state that we need in the tests, e.g. "alter database ... set ..." - const migrationDb = new bsky.PrimaryDatabase({ - url: cfg.dbPrimaryPostgresUrl, + const migrationDb = new bsky.Database({ + url: cfg.dbPostgresUrl, schema: cfg.dbPostgresSchema, }) if (cfg.migration) { @@ -76,285 +79,52 @@ export class TestBsky { } await migrationDb.close() - const ns = cfg.dbPostgresSchema - ? await randomIntFromSeed(cfg.dbPostgresSchema, 1000000) - : undefined - assert(config.redisHost) - const redisCache = new bsky.Redis({ - host: config.redisHost, - namespace: `ns${ns}`, - db: 1, - }) - // api server const server = bsky.BskyAppView.create({ - db, - redis: redisCache, config, - imgInvalidator: cfg.imgInvalidator, signingKey: serviceKeypair, }) - // indexer - const indexerCfg = new bsky.IndexerConfig({ - version: '0.0.0', - serverDid, - didCacheStaleTTL: HOUR, - didCacheMaxTTL: DAY, - redisHost: cfg.redisHost, - dbPostgresUrl: cfg.dbPrimaryPostgresUrl, - dbPostgresSchema: cfg.dbPostgresSchema, - didPlcUrl: cfg.plcUrl, - labelerKeywords: { label_me: 'test-label', label_me_2: 'test-label-2' }, - imgUriEndpoint: 'img.example.com', - moderationPushUrl: - cfg.indexer?.moderationPushUrl ?? 'https://modservice.invalid', - indexerPartitionIds: [0], - indexerNamespace: `ns${ns}`, - indexerSubLockId: uniqueLockId(), - indexerPort: await getPort(), - ingesterPartitionCount: 1, - pushNotificationEndpoint: 'https://push.bsky.app/api/push', - ...(cfg.indexer ?? {}), - }) - assert(indexerCfg.redisHost) - const indexerRedis = new bsky.Redis({ - host: indexerCfg.redisHost, - namespace: `ns${ns}`, - }) - const indexer = bsky.BskyIndexer.create({ - cfg: indexerCfg, - db: db.getPrimary(), - redis: indexerRedis, - redisCache, - }) - // ingester - const ingesterCfg = new bsky.IngesterConfig({ - version: '0.0.0', - redisHost: cfg.redisHost, - dbPostgresUrl: cfg.dbPrimaryPostgresUrl, - dbPostgresSchema: cfg.dbPostgresSchema, - repoProvider: cfg.repoProvider, - labelProvider: cfg.labelProvider, - ingesterNamespace: `ns${ns}`, - ingesterSubLockId: uniqueLockId(), - ingesterPartitionCount: 1, - ...(cfg.ingester ?? {}), - }) - assert(ingesterCfg.redisHost) - const ingesterRedis = new bsky.Redis({ - host: ingesterCfg.redisHost, - namespace: ingesterCfg.ingesterNamespace, - }) - const ingester = bsky.BskyIngester.create({ - cfg: ingesterCfg, - db: db.getPrimary(), - redis: ingesterRedis, + const sub = new bsky.RepoSubscription({ + service: cfg.repoProvider, + db, + idResolver: dataplane.idResolver, + background: new BackgroundQueue(db), }) - await ingester.start() - await indexer.start() - await server.start() - // manually process labels in dev-env (in network.processAll) - ingester.ctx.labelSubscription?.destroy() + await server.start() + sub.run() - return new TestBsky(url, port, server, indexer, ingester) + return new TestBsky(url, port, db, server, dataplane, bsync, sub) } get ctx(): bsky.AppContext { return this.server.ctx } - get sub() { - return this.indexer.sub - } - getClient() { return new AtpAgent({ service: this.url }) } - adminAuth(role: 'admin' | 'moderator' | 'triage' = 'admin'): string { - const password = - role === 'triage' - ? this.ctx.cfg.triagePassword - : role === 'moderator' - ? this.ctx.cfg.moderatorPassword - : this.ctx.cfg.adminPassword + adminAuth(): string { + const [password] = this.ctx.cfg.adminPasswords return ( 'Basic ' + ui8.toString(ui8.fromString(`admin:${password}`, 'utf8'), 'base64pad') ) } - adminAuthHeaders(role?: 'admin' | 'moderator' | 'triage') { + adminAuthHeaders() { return { - authorization: this.adminAuth(role), + authorization: this.adminAuth(), } } - async processAll() { - await Promise.all([ - this.ctx.backgroundQueue.processAll(), - this.indexer.ctx.backgroundQueue.processAll(), - ]) - } - async close() { - await this.server.destroy({ skipDb: true, skipRedis: true }) - await this.ingester.destroy({ skipDb: true }) - await this.indexer.destroy() // closes shared db & redis - } -} - -// Below are used for tests just of component parts of the appview, i.e. ingester and indexers: - -export async function getIngester( - network: TestNetworkNoAppView, - opts: { name: string } & Partial, -) { - const { name, ...config } = opts - const ns = name ? await randomIntFromSeed(name, 1000000) : undefined - const cfg = new bsky.IngesterConfig({ - version: '0.0.0', - redisHost: process.env.REDIS_HOST || '', - dbPostgresUrl: process.env.DB_POSTGRES_URL || '', - dbPostgresSchema: `appview_${name}`, - repoProvider: network.pds.url.replace('http://', 'ws://'), - labelProvider: 'http://labeler.invalid', - ingesterSubLockId: uniqueLockId(), - ingesterPartitionCount: config.ingesterPartitionCount ?? 1, - ingesterNamespace: `ns${ns}`, - ...config, - }) - const db = new bsky.PrimaryDatabase({ - url: cfg.dbPostgresUrl, - schema: cfg.dbPostgresSchema, - }) - assert(cfg.redisHost) - const redis = new bsky.Redis({ - host: cfg.redisHost, - namespace: cfg.ingesterNamespace, - }) - await db.migrateToLatestOrThrow() - const ingester = await bsky.BskyIngester.create({ cfg, db, redis }) - await ingester.ctx.labelSubscription?.destroy() - return ingester -} - -// get multiple indexers for separate partitions, sharing db and redis instance. -export async function getIndexers( - network: TestNetworkNoAppView, - opts: Partial & { - name: string - partitionIdsByIndexer: number[][] - }, -): Promise { - const { name, ...config } = opts - const ns = name ? await randomIntFromSeed(name, 1000000) : undefined - const baseCfg: bsky.IndexerConfigValues = { - version: '0.0.0', - serverDid: 'did:example:bsky', - didCacheStaleTTL: HOUR, - didCacheMaxTTL: DAY, - labelerKeywords: { label_me: 'test-label', label_me_2: 'test-label-2' }, - redisHost: process.env.REDIS_HOST || '', - dbPostgresUrl: process.env.DB_POSTGRES_URL || '', - dbPostgresSchema: `appview_${name}`, - didPlcUrl: network.plc.url, - imgUriEndpoint: '', - indexerPartitionIds: [0], - indexerNamespace: `ns${ns}`, - ingesterPartitionCount: config.ingesterPartitionCount ?? 1, - moderationPushUrl: config.moderationPushUrl ?? 'https://modservice.invalid', - ...config, - } - const db = new bsky.PrimaryDatabase({ - url: baseCfg.dbPostgresUrl, - schema: baseCfg.dbPostgresSchema, - }) - assert(baseCfg.redisHost) - const redis = new bsky.Redis({ - host: baseCfg.redisHost, - namespace: baseCfg.indexerNamespace, - }) - const redisCache = new bsky.Redis({ - host: baseCfg.redisHost, - namespace: baseCfg.indexerNamespace, - db: 1, - }) - - const indexers = await Promise.all( - opts.partitionIdsByIndexer.map(async (indexerPartitionIds) => { - const cfg = new bsky.IndexerConfig({ - ...baseCfg, - indexerPartitionIds, - indexerSubLockId: uniqueLockId(), - indexerPort: await getPort(), - }) - return bsky.BskyIndexer.create({ cfg, db, redis, redisCache }) - }), - ) - await db.migrateToLatestOrThrow() - return { - db, - list: indexers, - async start() { - await Promise.all(indexers.map((indexer) => indexer.start())) - }, - async destroy() { - const stopping = [...indexers] - const lastIndexer = stopping.pop() - await Promise.all( - stopping.map((indexer) => - indexer.destroy({ skipDb: true, skipRedis: true }), - ), - ) - await lastIndexer?.destroy() - }, - } -} - -export type BskyIndexers = { - db: bsky.Database - list: bsky.BskyIndexer[] - start(): Promise - destroy(): Promise -} - -export async function processAll( - network: TestNetworkNoAppView, - ingester: bsky.BskyIngester, -) { - await network.pds.processAll() - await ingestAll(network, ingester) - // eslint-disable-next-line no-constant-condition - while (true) { - // check indexers - const keys = [...Array(ingester.sub.opts.partitionCount)].map( - (_, i) => `repo:${i}`, - ) - const results = await ingester.sub.ctx.redis.streamLengths(keys) - const indexersCaughtUp = results.every((len) => len === 0) - if (indexersCaughtUp) return - await wait(50) - } -} - -export async function ingestAll( - network: TestNetworkNoAppView, - ingester: bsky.BskyIngester, -) { - const sequencer = network.pds.ctx.sequencer - await network.pds.processAll() - // eslint-disable-next-line no-constant-condition - while (true) { - await wait(50) - // check ingester - const [ingesterCursor, curr] = await Promise.all([ - ingester.sub.getCursor(), - sequencer.curr(), - ]) - const ingesterCaughtUp = curr !== null && ingesterCursor === curr - if (ingesterCaughtUp) return + await this.server.destroy() + await this.bsync.destroy() + await this.dataplane.destroy() + await this.sub.destroy() + await this.db.close() } } diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index ab818dfc24c..f115a1112ef 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -1,5 +1,6 @@ import { AtUri } from '@atproto/syntax' import AtpAgent from '@atproto/api' +import { Database } from '@atproto/bsky' import { REASONSPAM, REASONOTHER, @@ -186,28 +187,16 @@ export async function generateMockSetup(env: TestNetwork) { }, ) - const ctx = env.bsky.ctx - if (ctx) { - const labelSrvc = ctx.services.label(ctx.db.getPrimary()) - await labelSrvc.createLabels([ - { - src: env.ozone.ctx.cfg.service.did, - uri: labeledPost.uri, - cid: labeledPost.cid, - val: 'nudity', - neg: false, - cts: new Date().toISOString(), - }, - { - src: env.ozone.ctx.cfg.service.did, - uri: filteredPost.uri, - cid: filteredPost.cid, - val: 'dmca-violation', - neg: false, - cts: new Date().toISOString(), - }, - ]) - } + await createLabel(env.bsky.db, { + uri: labeledPost.uri, + cid: labeledPost.cid, + val: 'nudity', + }) + await createLabel(env.bsky.db, { + uri: filteredPost.uri, + cid: filteredPost.cid, + val: 'dmca-violation', + }) // a set of replies for (let i = 0; i < 100; i++) { @@ -341,3 +330,20 @@ export async function generateMockSetup(env: TestNetwork) { function ucfirst(str: string): string { return str.at(0)?.toUpperCase() + str.slice(1) } + +const createLabel = async ( + db: Database, + opts: { uri: string; cid: string; val: string }, +) => { + await db.db + .insertInto('label') + .values({ + uri: opts.uri, + cid: opts.cid, + val: opts.val, + cts: new Date().toISOString(), + neg: false, + src: 'did:example:labeler', + }) + .execute() +} diff --git a/packages/dev-env/src/network-no-appview.ts b/packages/dev-env/src/network-no-appview.ts index 44701ece35e..3272245280c 100644 --- a/packages/dev-env/src/network-no-appview.ts +++ b/packages/dev-env/src/network-no-appview.ts @@ -32,7 +32,7 @@ export class TestNetworkNoAppView { return fg } - getSeedClient(): SeedClient { + getSeedClient(): SeedClient { const agent = this.pds.getClient() return new SeedClient(this, agent) } diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts index 4e29c3c9384..c90e2c181f5 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -56,16 +56,11 @@ export class TestNetwork extends TestNetworkNoAppView { plcUrl: plc.url, pdsPort, repoProvider: `ws://localhost:${pdsPort}`, - labelProvider: `http://localhost:${ozonePort}`, dbPostgresSchema: `appview_${dbPostgresSchema}`, - dbPrimaryPostgresUrl: dbPostgresUrl, + dbPostgresUrl, redisHost, modServiceDid: ozoneDid, ...params.bsky, - indexer: { - ...params.bsky?.indexer, - moderationPushUrl: `http://admin:${ADMIN_PASSWORD}@localhost:${ozonePort}`, - }, }) const pds = await TestPds.create({ @@ -98,13 +93,12 @@ export class TestNetwork extends TestNetworkNoAppView { } async processFullSubscription(timeout = 5000) { - const sub = this.bsky.indexer.sub + const sub = this.bsky.sub const start = Date.now() const lastSeq = await this.pds.ctx.sequencer.curr() if (!lastSeq) return while (Date.now() - start < timeout) { - const partitionState = sub.partitions.get(0) - if (partitionState?.cursor >= lastSeq) { + if (sub.seenSeq !== null && sub.seenSeq >= lastSeq) { // has seen last seq, just need to wait for it to finish processing await sub.repoQueue.main.onIdle() return @@ -117,9 +111,7 @@ export class TestNetwork extends TestNetworkNoAppView { async processAll(timeout?: number) { await this.pds.processAll() await this.processFullSubscription(timeout) - await this.bsky.processAll() - await this.ozone.processAll() - await this.bsky.ingester.ctx.labelSubscription?.fetchLabels() + await this.bsky.sub.background.processAll() } async serviceHeaders(did: string, aud?: string) { diff --git a/packages/dev-env/src/seed/basic.ts b/packages/dev-env/src/seed/basic.ts index 47c299dce45..45583813afb 100644 --- a/packages/dev-env/src/seed/basic.ts +++ b/packages/dev-env/src/seed/basic.ts @@ -1,7 +1,13 @@ -import { SeedClient } from './client' import usersSeed from './users' +import { TestBsky } from '../bsky' +import { TestNetwork } from '../network' +import { TestNetworkNoAppView } from '../network-no-appview' +import { SeedClient } from './client' -export default async (sc: SeedClient, users = true) => { +export default async ( + sc: SeedClient, + users = true, +) => { if (users) await usersSeed(sc) const alice = sc.dids.alice @@ -129,6 +135,25 @@ export default async (sc: SeedClient, users = true) => { await sc.repost(dan, sc.posts[alice][1].ref) await sc.repost(dan, alicesReplyToBob.ref) + if (sc.network instanceof TestNetwork) { + const bsky = sc.network.bsky + await createLabel(bsky, { + val: 'test-label', + uri: sc.posts[alice][2].ref.uriStr, + cid: sc.posts[alice][2].ref.cidStr, + }) + await createLabel(bsky, { + val: 'test-label', + uri: sc.replies[bob][0].ref.uriStr, + cid: sc.replies[bob][0].ref.cidStr, + }) + await createLabel(bsky, { + val: 'test-label-2', + uri: sc.replies[bob][0].ref.uriStr, + cid: sc.replies[bob][0].ref.cidStr, + }) + } + return sc } @@ -144,3 +169,20 @@ export const replies = { bob: ['hear that label_me label_me_2'], carol: ['of course'], } + +const createLabel = async ( + bsky: TestBsky, + opts: { uri: string; cid: string; val: string }, +) => { + await bsky.db.db + .insertInto('label') + .values({ + uri: opts.uri, + cid: opts.cid, + val: opts.val, + cts: new Date().toISOString(), + neg: false, + src: 'did:example:labeler', // this did is also configured on labelsFromIssuerDids + }) + .execute() +} diff --git a/packages/dev-env/src/seed/client.ts b/packages/dev-env/src/seed/client.ts index 5b7a614228f..984115731dd 100644 --- a/packages/dev-env/src/seed/client.ts +++ b/packages/dev-env/src/seed/client.ts @@ -46,7 +46,9 @@ export class RecordRef { } } -export class SeedClient { +export class SeedClient< + Network extends TestNetworkNoAppView = TestNetworkNoAppView, +> { accounts: Record< string, { @@ -82,7 +84,7 @@ export class SeedClient { > dids: Record - constructor(public network: TestNetworkNoAppView, public agent: AtpAgent) { + constructor(public network: Network, public agent: AtpAgent) { this.accounts = {} this.profiles = {} this.follows = {} diff --git a/packages/dev-env/src/types.ts b/packages/dev-env/src/types.ts index db4a7331ae0..dbedfaced4f 100644 --- a/packages/dev-env/src/types.ts +++ b/packages/dev-env/src/types.ts @@ -2,7 +2,6 @@ import * as pds from '@atproto/pds' import * as bsky from '@atproto/bsky' import * as bsync from '@atproto/bsync' import * as ozone from '@atproto/ozone' -import { ImageInvalidator } from '@atproto/bsky' import { ExportableKeypair } from '@atproto/crypto' export type PlcConfig = { @@ -18,14 +17,11 @@ export type PdsConfig = Partial & { export type BskyConfig = Partial & { plcUrl: string repoProvider: string - labelProvider: string - dbPrimaryPostgresUrl: string + dbPostgresUrl: string + dbPostgresSchema: string redisHost: string pdsPort: number - imgInvalidator?: ImageInvalidator migration?: string - indexer?: Partial - ingester?: Partial } export type BsyncConfig = Partial & { diff --git a/packages/dev-env/src/util.ts b/packages/dev-env/src/util.ts index 7d6091023f6..679ca89c7a8 100644 --- a/packages/dev-env/src/util.ts +++ b/packages/dev-env/src/util.ts @@ -7,7 +7,7 @@ export const mockNetworkUtilities = (pds: TestPds, bsky?: TestBsky) => { mockResolvers(pds.ctx.idResolver, pds) if (bsky) { mockResolvers(bsky.ctx.idResolver, pds) - mockResolvers(bsky.indexer.ctx.idResolver, pds) + mockResolvers(bsky.dataplane.idResolver, pds) } } diff --git a/packages/identity/src/did/atproto-data.ts b/packages/identity/src/did/atproto-data.ts index c03f76ef598..c0cd9829739 100644 --- a/packages/identity/src/did/atproto-data.ts +++ b/packages/identity/src/did/atproto-data.ts @@ -20,7 +20,13 @@ export { export const getKey = (doc: DidDocument): string | undefined => { const key = getSigningKey(doc) if (!key) return undefined + return getDidKeyFromMultibase(key) +} +export const getDidKeyFromMultibase = (key: { + type: string + publicKeyMultibase: string +}): string | undefined => { const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase) let didKey: string | undefined = undefined if (key.type === 'EcdsaSecp256r1VerificationKey2019') { diff --git a/packages/ozone/tests/__snapshots__/moderation-events.test.ts.snap b/packages/ozone/tests/__snapshots__/moderation-events.test.ts.snap index ac48d862f58..fcb73413622 100644 --- a/packages/ozone/tests/__snapshots__/moderation-events.test.ts.snap +++ b/packages/ozone/tests/__snapshots__/moderation-events.test.ts.snap @@ -5,85 +5,63 @@ Object { "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "user(2)", "event": Object { - "$type": "com.atproto.admin.defs#modEventLabel", - "comment": "[AutoModerator]: Applying labels", - "createLabelVals": Array [ - "test-label", - ], - "negateLabelVals": Array [], + "$type": "com.atproto.admin.defs#modEventReport", + "comment": "X", + "reportType": "com.atproto.moderation.defs#reasonMisleading", }, "id": 1, "subject": Object { - "$type": "com.atproto.admin.defs#recordView", - "blobCids": Array [], - "cid": "cids(0)", + "$type": "com.atproto.admin.defs#repoView", + "did": "user(0)", + "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", - "moderation": Object {}, - "repo": Object { - "did": "user(0)", - "handle": "alice.test", - "indexedAt": "1970-01-01T00:00:00.000Z", - "moderation": Object { - "subjectStatus": Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 1, - "lastReportedAt": "1970-01-01T00:00:00.000Z", - "lastReviewedAt": "1970-01-01T00:00:00.000Z", - "lastReviewedBy": "user(1)", - "reviewState": "com.atproto.admin.defs#reviewEscalated", - "subject": Object { - "$type": "com.atproto.admin.defs#repoRef", - "did": "user(0)", - }, - "subjectBlobCids": Array [], - "subjectRepoHandle": "alice.test", - "tags": Array [ - "lang:und", - ], - "takendown": false, - "updatedAt": "1970-01-01T00:00:00.000Z", + "moderation": Object { + "subjectStatus": Object { + "createdAt": "1970-01-01T00:00:00.000Z", + "id": 1, + "lastReportedAt": "1970-01-01T00:00:00.000Z", + "lastReviewedAt": "1970-01-01T00:00:00.000Z", + "lastReviewedBy": "user(1)", + "reviewState": "com.atproto.admin.defs#reviewEscalated", + "subject": Object { + "$type": "com.atproto.admin.defs#repoRef", + "did": "user(0)", }, + "subjectBlobCids": Array [], + "subjectRepoHandle": "alice.test", + "tags": Array [ + "lang:und", + ], + "takendown": false, + "updatedAt": "1970-01-01T00:00:00.000Z", }, - "relatedRecords": Array [ - Object { - "$type": "app.bsky.actor.profile", - "avatar": Object { - "$type": "blob", - "mimeType": "image/jpeg", - "ref": Object { - "$link": "cids(2)", - }, - "size": 3976, - }, - "description": "its me!", - "displayName": "ali", - "labels": Object { - "$type": "com.atproto.label.defs#selfLabels", - "values": Array [ - Object { - "val": "self-label-a", - }, - Object { - "val": "self-label-b", - }, - ], + }, + "relatedRecords": Array [ + Object { + "$type": "app.bsky.actor.profile", + "avatar": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(0)", }, + "size": 3976, }, - ], - }, - "uri": "record(0)", - "value": Object { - "$type": "app.bsky.feed.post", - "createdAt": "1970-01-01T00:00:00.000Z", - "embed": Object { - "$type": "app.bsky.embed.record", - "record": Object { - "cid": "cids(1)", - "uri": "record(1)", + "description": "its me!", + "displayName": "ali", + "labels": Object { + "$type": "com.atproto.label.defs#selfLabels", + "values": Array [ + Object { + "val": "self-label-a", + }, + Object { + "val": "self-label-b", + }, + ], }, }, - "text": "yoohoo label_me", - }, + ], }, "subjectBlobCids": Array [], "subjectBlobs": Array [], @@ -101,7 +79,7 @@ Array [ "comment": "X", "reportType": "com.atproto.moderation.defs#reasonSpam", }, - "id": 13, + "id": 11, "subject": Object { "$type": "com.atproto.admin.defs#repoRef", "did": "user(0)", @@ -120,7 +98,7 @@ Array [ ], "remove": Array [], }, - "id": 8, + "id": 6, "subject": Object { "$type": "com.atproto.admin.defs#repoRef", "did": "user(0)", @@ -137,7 +115,7 @@ Array [ "comment": "X", "reportType": "com.atproto.moderation.defs#reasonSpam", }, - "id": 7, + "id": 5, "subject": Object { "$type": "com.atproto.admin.defs#repoRef", "did": "user(0)", @@ -159,7 +137,7 @@ Array [ "comment": "X", "reportType": "com.atproto.moderation.defs#reasonSpam", }, - "id": 12, + "id": 10, "subject": Object { "$type": "com.atproto.repo.strongRef", "cid": "cids(0)", @@ -178,7 +156,7 @@ Array [ ], "remove": Array [], }, - "id": 6, + "id": 4, "subject": Object { "$type": "com.atproto.repo.strongRef", "cid": "cids(0)", @@ -196,7 +174,7 @@ Array [ "comment": "X", "reportType": "com.atproto.moderation.defs#reasonSpam", }, - "id": 5, + "id": 3, "subject": Object { "$type": "com.atproto.repo.strongRef", "cid": "cids(0)", diff --git a/packages/ozone/tests/__snapshots__/moderation.test.ts.snap b/packages/ozone/tests/__snapshots__/moderation.test.ts.snap index 1cd4c192081..39d2b6d4890 100644 --- a/packages/ozone/tests/__snapshots__/moderation.test.ts.snap +++ b/packages/ozone/tests/__snapshots__/moderation.test.ts.snap @@ -4,7 +4,7 @@ exports[`moderation reporting creates reports of a record. 1`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", - "id": 7, + "id": 5, "reasonType": "com.atproto.moderation.defs#reasonSpam", "reportedBy": "user(0)", "subject": Object { @@ -15,7 +15,7 @@ Array [ }, Object { "createdAt": "1970-01-01T00:00:00.000Z", - "id": 9, + "id": 7, "reason": "defamation", "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(1)", @@ -32,7 +32,7 @@ exports[`moderation reporting creates reports of a repo. 1`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", - "id": 3, + "id": 1, "reasonType": "com.atproto.moderation.defs#reasonSpam", "reportedBy": "user(0)", "subject": Object { @@ -42,7 +42,7 @@ Array [ }, Object { "createdAt": "1970-01-01T00:00:00.000Z", - "id": 5, + "id": 3, "reason": "impersonation", "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(2)", diff --git a/packages/ozone/tests/moderation-events.test.ts b/packages/ozone/tests/moderation-events.test.ts index 12277ea77a4..fbe571a8172 100644 --- a/packages/ozone/tests/moderation-events.test.ts +++ b/packages/ozone/tests/moderation-events.test.ts @@ -22,13 +22,13 @@ describe('moderation-events', () => { ) => { return pdsAgent.api.com.atproto.admin.emitModerationEvent(eventData, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.ozone.adminAuthHeaders('moderator'), }) } const queryModerationEvents = (eventQuery) => agent.api.com.atproto.admin.queryModerationEvents(eventQuery, { - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.ozone.adminAuthHeaders('moderator'), }) const seedEvents = async () => { @@ -203,11 +203,11 @@ describe('moderation-events', () => { const defaultEvents = await getPaginatedEvents() const reversedEvents = await getPaginatedEvents('asc') - expect(allEvents.data.events.length).toEqual(7) + expect(allEvents.data.events.length).toEqual(6) expect(defaultEvents.length).toEqual(allEvents.data.events.length) expect(reversedEvents.length).toEqual(allEvents.data.events.length) // First event in the reversed list is the last item in the default list - expect(reversedEvents[0].id).toEqual(defaultEvents[6].id) + expect(reversedEvents[0].id).toEqual(defaultEvents[5].id) }) it('returns report events matching reportType filters', async () => { @@ -240,7 +240,7 @@ describe('moderation-events', () => { expect(eventsWithX.data.events.length).toEqual(10) expect(eventsWithTest.data.events.length).toEqual(0) - expect(eventsWithComment.data.events.length).toEqual(12) + expect(eventsWithComment.data.events.length).toEqual(10) }) it('returns events matching filter params for labels', async () => { @@ -325,7 +325,7 @@ describe('moderation-events', () => { }) const addEvent = await tagEvent({ add: ['L1', 'L2'], remove: [] }) const addAndRemoveEvent = await tagEvent({ add: ['L3'], remove: ['L2'] }) - const [addFinder, addAndRemoveFinder, removeFinder] = await Promise.all([ + const [addFinder, addAndRemoveFinder, _removeFinder] = await Promise.all([ queryModerationEvents({ addedTags: ['L1'], }), @@ -356,7 +356,7 @@ describe('moderation-events', () => { it('gets an event by specific id', async () => { const { data } = await pdsAgent.api.com.atproto.admin.getModerationEvent( { id: 1 }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.ozone.adminAuthHeaders('moderator') }, ) expect(forSnapshot(data)).toMatchSnapshot() }) diff --git a/packages/ozone/tests/moderation-statuses.test.ts b/packages/ozone/tests/moderation-statuses.test.ts index 14184454e62..527611d5313 100644 --- a/packages/ozone/tests/moderation-statuses.test.ts +++ b/packages/ozone/tests/moderation-statuses.test.ts @@ -19,13 +19,13 @@ describe('moderation-statuses', () => { const emitModerationEvent = async (eventData) => { return pdsAgent.api.com.atproto.admin.emitModerationEvent(eventData, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.ozone.adminAuthHeaders('moderator'), }) } const queryModerationStatuses = (statusQuery) => agent.api.com.atproto.admin.queryModerationStatuses(statusQuery, { - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.ozone.adminAuthHeaders('moderator'), }) const seedEvents = async () => { diff --git a/packages/ozone/tests/moderation.test.ts b/packages/ozone/tests/moderation.test.ts index b9899bdcdab..79aae3938c9 100644 --- a/packages/ozone/tests/moderation.test.ts +++ b/packages/ozone/tests/moderation.test.ts @@ -607,7 +607,7 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('triage'), + headers: network.ozone.adminAuthHeaders('triage'), }, ) await expect(attemptLabel).rejects.toThrow( @@ -748,7 +748,7 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('moderator'), + headers: network.ozone.adminAuthHeaders('moderator'), }, ) // cleanup @@ -775,7 +775,7 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('triage'), + headers: network.ozone.adminAuthHeaders('triage'), }, ) await expect(attemptTakedownTriage).rejects.toThrow( @@ -800,7 +800,7 @@ describe('moderation', () => { const { data: statusesAfterTakedown } = await agent.api.com.atproto.admin.queryModerationStatuses( { subject: sc.dids.bob }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.ozone.adminAuthHeaders('moderator') }, ) expect(statusesAfterTakedown.subjectStatuses[0]).toMatchObject({ @@ -818,11 +818,11 @@ describe('moderation', () => { const [{ data: eventList }, { data: statuses }] = await Promise.all([ agent.api.com.atproto.admin.queryModerationEvents( { subject: sc.dids.bob }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.ozone.adminAuthHeaders('moderator') }, ), agent.api.com.atproto.admin.queryModerationStatuses( { subject: sc.dids.bob }, - { headers: network.bsky.adminAuthHeaders('moderator') }, + { headers: network.ozone.adminAuthHeaders('moderator') }, ), ]) @@ -879,7 +879,7 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), + headers: network.ozone.adminAuthHeaders(), }, ) return result.data @@ -901,7 +901,7 @@ describe('moderation', () => { }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), + headers: network.ozone.adminAuthHeaders(), }, ) } @@ -909,7 +909,7 @@ describe('moderation', () => { async function getRecordLabels(uri: string) { const result = await agent.api.com.atproto.admin.getRecord( { uri }, - { headers: network.bsky.adminAuthHeaders() }, + { headers: network.ozone.adminAuthHeaders() }, ) const labels = result.data.labels ?? [] return labels.map((l) => l.val) @@ -918,7 +918,7 @@ describe('moderation', () => { async function getRepoLabels(did: string) { const result = await agent.api.com.atproto.admin.getRepo( { did }, - { headers: network.bsky.adminAuthHeaders() }, + { headers: network.ozone.adminAuthHeaders() }, ) const labels = result.data.labels ?? [] return labels.map((l) => l.val) @@ -933,7 +933,7 @@ describe('moderation', () => { const { ctx } = network.bsky post = sc.posts[sc.dids.carol][0] blob = post.images[1] - imageUri = ctx.imgUriBuilder + imageUri = ctx.views.imgUriBuilder .getPresetUri( 'feed_thumbnail', sc.dids.carol, @@ -974,7 +974,8 @@ describe('moderation', () => { }) }) - it('prevents image blob from being served, even when cached.', async () => { + // @TODO add back in with image invalidation, see bluesky-social/atproto#2087 + it.skip('prevents image blob from being served, even when cached.', async () => { const fetchImage = await fetch(imageUri) expect(fetchImage.status).toEqual(404) expect(await fetchImage.json()).toEqual({ message: 'Image not found' }) diff --git a/packages/ozone/tests/repo-search.test.ts b/packages/ozone/tests/repo-search.test.ts index 0d41b014c1b..1704e934206 100644 --- a/packages/ozone/tests/repo-search.test.ts +++ b/packages/ozone/tests/repo-search.test.ts @@ -117,7 +117,6 @@ describe('admin repo search view', () => { { headers }, ) - expect(full.data.repos.length).toEqual(15) expect(results(paginatedAll)).toEqual(results([full.data])) }) }) diff --git a/packages/pds/tests/_util.ts b/packages/pds/tests/_util.ts index 5624ac9a65a..ee57c62b9fe 100644 --- a/packages/pds/tests/_util.ts +++ b/packages/pds/tests/_util.ts @@ -43,11 +43,11 @@ export const forSnapshot = (obj: unknown) => { return constantDate } } - if (str.match(/^\d+::bafy/)) { + // handles both pds and appview cursor separators + if (str.match(/^\d+(?:__|::)bafy/)) { return constantKeysetCursor } - - if (str.match(/^\d+::did:plc/)) { + if (str.match(/^\d+(?:__|::)did:plc/)) { return constantDidCursor } if (str.match(/\/image\/[^/]+\/.+\/did:plc:[^/]+\/[^/]+@[\w]+$/)) { diff --git a/packages/pds/tests/proxied/__snapshots__/feedgen.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/feedgen.test.ts.snap index 1d68928d7e0..e4c09b14c51 100644 --- a/packages/pds/tests/proxied/__snapshots__/feedgen.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/feedgen.test.ts.snap @@ -86,13 +86,13 @@ Object { "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(4)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(4)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(4)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(4)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(5)@jpeg", }, ], }, @@ -100,8 +100,8 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", + "did": "user(4)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 73781d5435a..22d39d28164 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -103,7 +103,7 @@ Object { Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "user(2)", "val": "repo-action-label", }, @@ -114,7 +114,7 @@ Object { }, }, ], - "cursor": "1:3", + "cursor": "1:2:3", } `; @@ -183,7 +183,7 @@ Array [ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(6)", + "src": "did:example:labeler", "uri": "user(5)", "val": "repo-action-label", }, @@ -258,7 +258,7 @@ Array [ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(6)", + "src": "did:example:labeler", "uri": "user(5)", "val": "repo-action-label", }, @@ -303,24 +303,7 @@ Object { ], }, "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(0)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(2)", - "uri": "record(0)", - "val": "test-label", - }, - Object { - "cid": "cids(0)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(2)", - "uri": "record(0)", - "val": "test-label-2", - }, - ], + "labels": Array [], "likeCount": 0, "record": Object { "$type": "app.bsky.feed.post", @@ -362,8 +345,8 @@ Object { "parent": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", "displayName": "ali", "handle": "alice.test", "labels": Array [ @@ -371,7 +354,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "user(2)", "uri": "record(4)", "val": "self-label-a", }, @@ -379,7 +362,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "user(2)", "uri": "record(4)", "val": "self-label-b", }, @@ -406,8 +389,8 @@ Object { "root": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", - "did": "user(3)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", "displayName": "ali", "handle": "alice.test", "labels": Array [ @@ -415,7 +398,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "user(2)", "uri": "record(4)", "val": "self-label-a", }, @@ -423,7 +406,7 @@ Object { "cid": "cids(4)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "user(2)", "uri": "record(4)", "val": "self-label-b", }, @@ -525,9 +508,11 @@ Object { "cid": "cids(0)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(1)@jpeg", + "description": "its me!", "did": "user(1)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", @@ -568,9 +553,11 @@ Object { "cid": "cids(0)", "creator": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(1)@jpeg", + "description": "its me!", "did": "user(1)", "displayName": "ali", "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { "cid": "cids(2)", @@ -753,24 +740,7 @@ Object { ], }, "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(4)", - "uri": "record(3)", - "val": "test-label", - }, - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(4)", - "uri": "record(3)", - "val": "test-label-2", - }, - ], + "labels": Array [], "likeCount": 0, "record": Object { "$type": "app.bsky.feed.post", @@ -881,24 +851,7 @@ Object { ], }, "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(4)", - "uri": "record(3)", - "val": "test-label", - }, - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(4)", - "uri": "record(3)", - "val": "test-label-2", - }, - ], + "labels": Array [], "likeCount": 0, "record": Object { "$type": "app.bsky.feed.post", @@ -1063,14 +1016,14 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(5)", + "did": "user(4)", "handle": "dan.test", "labels": Array [ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", - "uri": "user(5)", + "src": "did:example:labeler", + "uri": "user(4)", "val": "repo-action-label", }, ], @@ -1087,7 +1040,7 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -1177,16 +1130,7 @@ Object { }, }, "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(6)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(4)", - "uri": "record(6)", - "val": "test-label", - }, - ], + "labels": Array [], "likeCount": 2, "record": Object { "$type": "app.bsky.feed.post", @@ -1621,7 +1565,7 @@ Object { Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "user(2)", "val": "repo-action-label", }, @@ -1638,8 +1582,8 @@ Object { "parent": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", - "did": "user(4)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -1656,30 +1600,13 @@ Object { "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(4)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(4)/cids(5)@jpeg", }, ], }, "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(3)", - "uri": "record(3)", - "val": "test-label", - }, - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(3)", - "uri": "record(3)", - "val": "test-label-2", - }, - ], + "labels": Array [], "likeCount": 0, "record": Object { "$type": "app.bsky.feed.post", @@ -1816,7 +1743,7 @@ Object { Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "user(2)", "val": "repo-action-label", }, @@ -1839,7 +1766,7 @@ Object { Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "user(2)", "val": "repo-action-label", }, @@ -1856,7 +1783,7 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -1875,13 +1802,13 @@ Object { "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(5)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(8)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(8)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(8)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(8)@jpeg", }, ], }, @@ -1889,8 +1816,8 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", - "did": "user(4)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -2001,7 +1928,7 @@ Object { "reason": Object { "$type": "app.bsky.feed.defs#reasonRepost", "by": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2072,8 +1999,8 @@ Object { "parent": Object { "$type": "app.bsky.feed.defs#postView", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", - "did": "user(4)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -2090,30 +2017,13 @@ Object { "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(4)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(4)/cids(5)@jpeg", }, ], }, "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(3)", - "uri": "record(3)", - "val": "test-label", - }, - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(3)", - "uri": "record(3)", - "val": "test-label-2", - }, - ], + "labels": Array [], "likeCount": 0, "record": Object { "$type": "app.bsky.feed.post", @@ -2200,7 +2110,7 @@ Object { Object { "post": Object { "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2328,8 +2238,8 @@ Object { Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", - "did": "user(4)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -2346,30 +2256,13 @@ Object { "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(4)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(4)/cids(5)@jpeg", }, ], }, "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(3)", - "uri": "record(3)", - "val": "test-label", - }, - Object { - "cid": "cids(4)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(3)", - "uri": "record(3)", - "val": "test-label-2", - }, - ], + "labels": Array [], "likeCount": 0, "record": Object { "$type": "app.bsky.feed.post", @@ -2540,7 +2433,7 @@ Object { Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "user(2)", "val": "repo-action-label", }, @@ -2558,7 +2451,7 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2648,16 +2541,7 @@ Object { }, }, "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(11)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(3)", - "uri": "record(13)", - "val": "test-label", - }, - ], + "labels": Array [], "likeCount": 2, "record": Object { "$type": "app.bsky.feed.post", @@ -2680,8 +2564,8 @@ Object { Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", - "did": "user(4)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -2761,7 +2645,7 @@ Object { Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "user(2)", "val": "repo-action-label", }, @@ -2778,7 +2662,7 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2797,13 +2681,13 @@ Object { "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(5)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(8)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(8)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(8)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(8)@jpeg", }, ], }, @@ -2811,8 +2695,8 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", - "did": "user(4)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -2930,7 +2814,7 @@ Object { Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", + "src": "did:example:labeler", "uri": "user(2)", "val": "repo-action-label", }, @@ -2959,7 +2843,7 @@ Object { Object { "post": Object { "author": Object { - "did": "user(6)", + "did": "user(5)", "handle": "carol.test", "labels": Array [], "viewer": Object { @@ -2977,13 +2861,13 @@ Object { "images": Array [ Object { "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(5)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(5)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(5)@jpeg", }, Object { "alt": "../dev-env/src/seed/img/key-alt.jpg", - "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(7)/cids(8)@jpeg", - "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(7)/cids(8)@jpeg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(6)/cids(8)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(6)/cids(8)@jpeg", }, ], }, @@ -2991,8 +2875,8 @@ Object { "record": Object { "$type": "app.bsky.embed.record#viewRecord", "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", - "did": "user(4)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -3075,8 +2959,8 @@ Object { Object { "post": Object { "author": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(1)@jpeg", - "did": "user(4)", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "labels": Array [], @@ -3215,7 +3099,7 @@ Object { Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(1)", + "src": "did:example:labeler", "uri": "user(0)", "val": "repo-action-label", }, @@ -3227,9 +3111,9 @@ Object { }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(0)@jpeg", "description": "its me!", - "did": "user(2)", + "did": "user(1)", "displayName": "ali", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -3238,7 +3122,7 @@ Object { "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(2)", + "src": "user(1)", "uri": "record(1)", "val": "self-label-a", }, @@ -3246,7 +3130,7 @@ Object { "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(2)", + "src": "user(1)", "uri": "record(1)", "val": "self-label-b", }, @@ -3258,9 +3142,9 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(0)@jpeg", "description": "hi im bob label_me", - "did": "user(4)", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -3515,48 +3399,6 @@ Object { exports[`proxies view requests unspecced.getPopularFeedGenerators 1`] = ` Object { - "cursor": "0000000000000::bafycid", - "feeds": Array [ - Object { - "cid": "cids(0)", - "creator": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", - "description": "its me!", - "did": "user(0)", - "displayName": "ali", - "handle": "alice.test", - "indexedAt": "1970-01-01T00:00:00.000Z", - "labels": Array [ - Object { - "cid": "cids(2)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(0)", - "uri": "record(1)", - "val": "self-label-a", - }, - Object { - "cid": "cids(2)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(0)", - "uri": "record(1)", - "val": "self-label-b", - }, - ], - "viewer": Object { - "blockedBy": false, - "muted": false, - }, - }, - "description": "Provides all feed candidates", - "did": "did:example:feedgen", - "displayName": "All", - "indexedAt": "1970-01-01T00:00:00.000Z", - "likeCount": 0, - "uri": "record(0)", - "viewer": Object {}, - }, - ], + "feeds": Array [], } `; diff --git a/packages/pds/tests/proxied/admin.test.ts b/packages/pds/tests/proxied/admin.test.ts index 9906e4d129a..3637b22878c 100644 --- a/packages/pds/tests/proxied/admin.test.ts +++ b/packages/pds/tests/proxied/admin.test.ts @@ -8,7 +8,8 @@ import { import { forSnapshot } from '../_util' import { NotFoundError } from '@atproto/api/src/client/types/app/bsky/feed/getPostThread' -describe('proxies admin requests', () => { +// @TODO skipping during appview v2 buildout, as appview frontends no longer contains moderation endpoints +describe.skip('proxies admin requests', () => { let network: TestNetwork let agent: AtpAgent let sc: SeedClient @@ -32,7 +33,7 @@ describe('proxies admin requests', () => { ) await basicSeed(sc, { inviteCode: invite.code, - addModLabels: true, + addModLabels: network.bsky, }) await network.processAll() }) diff --git a/packages/pds/tests/proxied/feedgen.test.ts b/packages/pds/tests/proxied/feedgen.test.ts index 53e50581b3f..c3cd5d208d6 100644 --- a/packages/pds/tests/proxied/feedgen.test.ts +++ b/packages/pds/tests/proxied/feedgen.test.ts @@ -8,7 +8,6 @@ describe('feedgen proxy view', () => { let network: TestNetwork let agent: AtpAgent let sc: SeedClient - let feedUri: AtUri beforeAll(async () => { @@ -17,7 +16,7 @@ describe('feedgen proxy view', () => { }) agent = network.pds.getClient() sc = network.getSeedClient() - await basicSeed(sc, { addModLabels: true }) + await basicSeed(sc, { addModLabels: network.bsky }) feedUri = AtUri.make(sc.dids.alice, 'app.bsky.feed.generator', 'mutuals') diff --git a/packages/pds/tests/proxied/procedures.test.ts b/packages/pds/tests/proxied/procedures.test.ts index 8c246e38da7..8b488621c41 100644 --- a/packages/pds/tests/proxied/procedures.test.ts +++ b/packages/pds/tests/proxied/procedures.test.ts @@ -17,7 +17,7 @@ describe('proxies appview procedures', () => { }) agent = network.pds.getClient() sc = network.getSeedClient() - await basicSeed(sc, { addModLabels: true }) + await basicSeed(sc, { addModLabels: network.bsky }) await network.processAll() alice = sc.dids.alice bob = sc.dids.bob @@ -142,7 +142,11 @@ describe('proxies appview procedures', () => { { headers: sc.getHeaders(alice) }, ) expect(result1.notifications.length).toBeGreaterThanOrEqual(5) - expect(result1.notifications.every((n) => !n.isRead)).toBe(true) + expect( + result1.notifications.every((n, i) => { + return (i === 0 && !n.isRead) || (i !== 0 && n.isRead) + }), + ).toBe(true) // update last seen const { indexedAt: lastSeenAt } = result1.notifications[2] await agent.api.app.bsky.notification.updateSeen( @@ -163,7 +167,7 @@ describe('proxies appview procedures', () => { expect(result2.notifications).toEqual( result1.notifications.map((n) => ({ ...n, - isRead: n.indexedAt <= lastSeenAt, + isRead: n.indexedAt < lastSeenAt, })), ) }) diff --git a/packages/pds/tests/proxied/read-after-write.test.ts b/packages/pds/tests/proxied/read-after-write.test.ts index 1bd6a463c34..5f70416751a 100644 --- a/packages/pds/tests/proxied/read-after-write.test.ts +++ b/packages/pds/tests/proxied/read-after-write.test.ts @@ -22,7 +22,7 @@ describe('proxy read after write', () => { }) agent = network.pds.getClient() sc = network.getSeedClient() - await basicSeed(sc, { addModLabels: true }) + await basicSeed(sc, { addModLabels: network.bsky }) await network.processAll() alice = sc.dids.alice carol = sc.dids.carol diff --git a/packages/pds/tests/proxied/views.test.ts b/packages/pds/tests/proxied/views.test.ts index 94b76719d70..9cfb7063bdf 100644 --- a/packages/pds/tests/proxied/views.test.ts +++ b/packages/pds/tests/proxied/views.test.ts @@ -19,7 +19,7 @@ describe('proxies view requests', () => { }) agent = network.pds.getClient() sc = network.getSeedClient() - await basicSeed(sc, { addModLabels: true }) + await basicSeed(sc, { addModLabels: network.bsky }) alice = sc.dids.alice bob = sc.dids.bob carol = sc.dids.carol @@ -79,9 +79,8 @@ describe('proxies view requests', () => { { did: sc.dids.carol, order: 2 }, { did: sc.dids.dan, order: 3 }, ] - await network.bsky.ctx.db - .getPrimary() - .db.insertInto('suggested_follow') + await network.bsky.db.db + .insertInto('suggested_follow') .values(suggestions) .execute() @@ -334,7 +333,8 @@ describe('proxies view requests', () => { expect([...pt1.data.feed, ...pt2.data.feed]).toEqual(res.data.feed) }) - it('unspecced.getPopularFeedGenerators', async () => { + // @TODO disabled during appview v2 buildout + it.skip('unspecced.getPopularFeedGenerators', async () => { const res = await agent.api.app.bsky.unspecced.getPopularFeedGenerators( {}, { diff --git a/packages/pds/tests/seeds/basic.ts b/packages/pds/tests/seeds/basic.ts index 1590fda3d05..c0dbf009213 100644 --- a/packages/pds/tests/seeds/basic.ts +++ b/packages/pds/tests/seeds/basic.ts @@ -1,10 +1,10 @@ -import { SeedClient } from '@atproto/dev-env' +import { SeedClient, TestBsky } from '@atproto/dev-env' import { ids } from '../../src/lexicon/lexicons' import usersSeed from './users' export default async ( sc: SeedClient, - opts?: { inviteCode?: string; addModLabels?: boolean }, + opts?: { inviteCode?: string; addModLabels?: TestBsky }, ) => { await usersSeed(sc, opts) @@ -134,24 +134,7 @@ export default async ( await sc.repost(dan, alicesReplyToBob.ref) if (opts?.addModLabels) { - await sc.agent.com.atproto.admin.emitModerationEvent( - { - event: { - createLabelVals: ['repo-action-label'], - negateLabelVals: [], - $type: 'com.atproto.admin.defs#modEventLabel', - }, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: dan, - }, - createdBy: 'did:example:admin', - }, - { - encoding: 'application/json', - headers: sc.adminAuthHeaders(), - }, - ) + await createLabel(opts.addModLabels, { did: dan, val: 'repo-action-label' }) } return sc @@ -169,3 +152,20 @@ export const replies = { bob: ['hear that label_me label_me_2'], carol: ['of course'], } + +const createLabel = async ( + bsky: TestBsky, + opts: { did: string; val: string }, +) => { + await bsky.db.db + .insertInto('label') + .values({ + uri: opts.did, + cid: '', + val: opts.val, + cts: new Date().toISOString(), + neg: false, + src: 'did:example:labeler', + }) + .execute() +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fba3901ac38..8ec8d78ae3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + importers: .: @@ -191,6 +195,9 @@ importers: '@connectrpc/connect': specifier: ^1.1.4 version: 1.3.0(@bufbuild/protobuf@1.6.0) + '@connectrpc/connect-express': + specifier: ^1.1.4 + version: 1.3.0(@bufbuild/protobuf@1.6.0)(@connectrpc/connect-node@1.3.0)(@connectrpc/connect@1.3.0) '@connectrpc/connect-node': specifier: ^1.1.4 version: 1.3.0(@bufbuild/protobuf@1.6.0)(@connectrpc/connect@1.3.0) @@ -300,6 +307,9 @@ importers: axios: specifier: ^0.27.2 version: 0.27.2 + http2-express-bridge: + specifier: ^1.0.7 + version: 1.0.7 packages/bsync: dependencies: @@ -931,12 +941,12 @@ importers: services/bsky: dependencies: - '@atproto/aws': - specifier: workspace:^ - version: link:../../packages/aws '@atproto/bsky': specifier: workspace:^ version: link:../../packages/bsky + '@atproto/crypto': + specifier: workspace:^ + version: link:../../packages/crypto dd-trace: specifier: 3.13.2 version: 3.13.2 @@ -7227,10 +7237,19 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + /depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + dev: true + /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + /destroy@1.0.4: + resolution: {integrity: sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==} + dev: true + /destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -8479,6 +8498,17 @@ packages: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} dev: true + /http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + dev: true + /http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -8509,6 +8539,17 @@ packages: roarr: 7.15.1 type-fest: 2.19.0 + /http2-express-bridge@1.0.7: + resolution: {integrity: sha512-bmzZSyn3nuzXRqs/+WgH7IGOQYMCIZNJeqTJ/1AoDgMPTSP5wXQCxPGsdUbGzzxwiHrMwyT4Z7t8ccbsKqiHrw==} + engines: {node: '>= 10.0.0'} + dependencies: + merge-descriptors: 1.0.1 + send: 0.17.2 + setprototypeof: 1.2.0 + transitivePeerDependencies: + - supports-color + dev: true + /https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -10035,6 +10076,13 @@ packages: /on-exit-leak-free@2.1.0: resolution: {integrity: sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==} + /on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: true + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -10889,6 +10937,27 @@ packages: dependencies: lru-cache: 6.0.0 + /send@0.17.2: + resolution: {integrity: sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==} + engines: {node: '>= 0.8.0'} + dependencies: + debug: 2.6.9 + depd: 1.1.2 + destroy: 1.0.4 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 1.8.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.3.0 + range-parser: 1.2.1 + statuses: 1.5.0 + transitivePeerDependencies: + - supports-color + dev: true + /send@0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} @@ -11122,6 +11191,11 @@ packages: /standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + /statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + dev: true + /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -11975,7 +12049,3 @@ packages: /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false diff --git a/services/bsky/Dockerfile b/services/bsky/Dockerfile index 9da764ecc3d..84422945ae0 100644 --- a/services/bsky/Dockerfile +++ b/services/bsky/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine as build +FROM node:20.11-alpine as build RUN npm install -g pnpm @@ -8,7 +8,6 @@ COPY ./*.* ./ # NOTE bsky's transitive dependencies go here: if that changes, this needs to be updated. COPY ./packages/bsky ./packages/bsky COPY ./packages/api ./packages/api -COPY ./packages/aws ./packages/aws COPY ./packages/common ./packages/common COPY ./packages/common-web ./packages/common-web COPY ./packages/crypto ./packages/crypto @@ -34,7 +33,7 @@ RUN pnpm install --prod --shamefully-hoist --frozen-lockfile --prefer-offline > WORKDIR services/bsky # Uses assets from build stage to reduce build size -FROM node:18-alpine +FROM node:20.11-alpine RUN apk add --update dumb-init diff --git a/services/bsky/README.md b/services/bsky/README.md new file mode 100644 index 00000000000..fea1bf3c602 --- /dev/null +++ b/services/bsky/README.md @@ -0,0 +1,23 @@ +# bsky appview service + +This is the service entrypoint for the bsky appview. The entrypoint command should run `api.js` with node, e.g. `node api.js`. The following env vars are supported: + +- `BSKY_PUBLIC_URL` - (required) the public url of the appview, e.g. `https://api.bsky.app`. +- `BSKY_DID_PLC_URL` - (required) the url of the PLC service used for looking up did documents, e.g. `https://plc.directory`. +- `BSKY_DATAPLANE_URL` - (required) the url where the backing dataplane service lives. +- `BSKY_SERVICE_SIGNING_KEY` - (required) the public signing key in the form of a `did:key`, used for service-to-service auth. Advertised in the appview's `did:web`` document. +- `BSKY_ADMIN_PASSWORDS` - (alt. `BSKY_ADMIN_PASSWORD`) (required) comma-separated list of admin passwords used for role-based auth. +- `NODE_ENV` - (recommended) for production usage, should be set to `production`. Otherwise all responses are validated on their way out. There may be other effects of not setting this to `production`, as dependencies may also implement debug modes based on its value. +- `BSKY_VERSION` - (recommended) version of the bsky service. This is advertised by the health endpoint. +- `BSKY_PORT` - (recommended) the port that the service will run on. +- `BSKY_IMG_URI_ENDPOINT` - (recommended) the base url for resized images, e.g. `https://cdn.bsky.app/img`. When not set, sets-up an image resizing service directly on the appview. +- `BSKY_SERVER_DID` - (recommended) the did of the appview service. When this is a `did:web` that matches the appview's public url, a `did:web` document is served. +- `BSKY_HANDLE_RESOLVE_NAMESERVERS` - alternative domain name servers used for handle resolution, comma-separated. +- `BSKY_BLOB_CACHE_LOC` - when `BSKY_IMG_URI_ENDPOINT` is not set, this determines where resized blobs are cached by the image resizing service. +- `BSKY_COURIER_URL` - URL of courier service. +- `BSKY_COURIER_API_KEY` - API key for courier service. +- `BSKY_BSYNC_URL` - URL of bsync service. +- `BSKY_BSYNC_API_KEY` - API key for bsync service. +- `BSKY_SEARCH_URL` - (alt. `BSKY_SEARCH_ENDPOINT`) - +- `BSKY_LABELS_FROM_ISSUER_DIDS` - comma-separated list of labelers to always use for record labels. +- `MOD_SERVICE_DID` - the DID of the mod service, used to receive service authed requests. diff --git a/services/bsky/api.js b/services/bsky/api.js index 230efd7fbf3..44d8f96b37d 100644 --- a/services/bsky/api.js +++ b/services/bsky/api.js @@ -1,8 +1,14 @@ 'use strict' /* eslint-disable */ -require('dd-trace') // Only works with commonjs - .init({ logInjection: true }) - .tracer.use('express', { +const dd = require('dd-trace') + +dd.tracer + .init() + .use('http2', { + client: true, // calls into dataplane + server: false, + }) + .use('express', { hooks: { request: (span, req) => { maintainXrpcResource(span, req) @@ -10,125 +16,39 @@ require('dd-trace') // Only works with commonjs }, }) +// modify tracer in order to track calls to dataplane as a service with proper resource names +const DATAPLANE_PREFIX = '/bsky.Service/' +const origStartSpan = dd.tracer._tracer.startSpan +dd.tracer._tracer.startSpan = function (name, options) { + if ( + name !== 'http.request' || + options?.tags?.component !== 'http2' || + !options?.tags?.['http.url'] + ) { + return origStartSpan.call(this, name, options) + } + const uri = new URL(options.tags['http.url']) + if (!uri.pathname.startsWith(DATAPLANE_PREFIX)) { + return origStartSpan.call(this, name, options) + } + options.tags['service.name'] = 'dataplane-bsky' + options.tags['resource.name'] = uri.pathname.slice(DATAPLANE_PREFIX.length) + return origStartSpan.call(this, name, options) +} + // Tracer code above must come before anything else -const path = require('path') -const assert = require('assert') +const path = require('node:path') +const assert = require('node:assert') const cluster = require('cluster') -const { - BunnyInvalidator, - CloudfrontInvalidator, - MultiImageInvalidator, -} = require('@atproto/aws') const { Secp256k1Keypair } = require('@atproto/crypto') -const { - DatabaseCoordinator, - PrimaryDatabase, - Redis, - ServerConfig, - BskyAppView, -} = require('@atproto/bsky') +const { ServerConfig, BskyAppView } = require('@atproto/bsky') const main = async () => { const env = getEnv() - assert(env.dbPrimaryPostgresUrl, 'missing configuration for db') - - if (env.enableMigrations) { - // separate db needed for more permissions - const migrateDb = new PrimaryDatabase({ - url: env.dbMigratePostgresUrl, - schema: env.dbPostgresSchema, - poolSize: 2, - }) - await migrateDb.migrateToLatestOrThrow() - await migrateDb.close() - } - - const db = new DatabaseCoordinator({ - schema: env.dbPostgresSchema, - primary: { - url: env.dbPrimaryPostgresUrl, - poolSize: env.dbPrimaryPoolSize || env.dbPoolSize, - poolMaxUses: env.dbPoolMaxUses, - poolIdleTimeoutMs: env.dbPoolIdleTimeoutMs, - }, - replicas: env.dbReplicaPostgresUrls?.map((url, i) => { - return { - url, - poolSize: env.dbPoolSize, - poolMaxUses: env.dbPoolMaxUses, - poolIdleTimeoutMs: env.dbPoolIdleTimeoutMs, - tags: getTagsForIdx(env.dbReplicaTags, i), - } - }), - }) - const cfg = ServerConfig.readEnv({ - port: env.port, - version: env.version, - dbPrimaryPostgresUrl: env.dbPrimaryPostgresUrl, - dbReplicaPostgresUrls: env.dbReplicaPostgresUrls, - dbReplicaTags: env.dbReplicaTags, - dbPostgresSchema: env.dbPostgresSchema, - publicUrl: env.publicUrl, - didPlcUrl: env.didPlcUrl, - imgUriSalt: env.imgUriSalt, - imgUriKey: env.imgUriKey, - imgUriEndpoint: env.imgUriEndpoint, - blobCacheLocation: env.blobCacheLocation, - }) - - const redis = new Redis( - cfg.redisSentinelName - ? { - sentinel: cfg.redisSentinelName, - hosts: cfg.redisSentinelHosts, - password: cfg.redisPassword, - db: 1, - commandTimeout: 500, - } - : { - host: cfg.redisHost, - password: cfg.redisPassword, - db: 1, - commandTimeout: 500, - }, - ) - + const config = ServerConfig.readEnv() + assert(env.serviceSigningKey, 'must set BSKY_SERVICE_SIGNING_KEY') const signingKey = await Secp256k1Keypair.import(env.serviceSigningKey) - - // configure zero, one, or more image invalidators - const imgInvalidators = [] - - if (env.bunnyAccessKey) { - imgInvalidators.push( - new BunnyInvalidator({ - accessKey: env.bunnyAccessKey, - urlPrefix: cfg.imgUriEndpoint, - }), - ) - } - - if (env.cfDistributionId) { - imgInvalidators.push( - new CloudfrontInvalidator({ - distributionId: env.cfDistributionId, - pathPrefix: cfg.imgUriEndpoint && new URL(cfg.imgUriEndpoint).pathname, - }), - ) - } - - const imgInvalidator = - imgInvalidators.length > 1 - ? new MultiImageInvalidator(imgInvalidators) - : imgInvalidators[0] - - const bsky = BskyAppView.create({ - db, - redis, - signingKey, - config: cfg, - imgInvalidator, - }) - + const bsky = BskyAppView.create({ config, signingKey }) await bsky.start() // Graceful shutdown (see also https://aws.amazon.com/blogs/containers/graceful-shutdowns-with-ecs/) const shutdown = async () => { @@ -139,63 +59,14 @@ const main = async () => { } const getEnv = () => ({ - enableMigrations: process.env.ENABLE_MIGRATIONS === 'true', - port: parseInt(process.env.PORT), - version: process.env.BSKY_VERSION, - dbMigratePostgresUrl: - process.env.DB_MIGRATE_POSTGRES_URL || process.env.DB_PRIMARY_POSTGRES_URL, - dbPrimaryPostgresUrl: process.env.DB_PRIMARY_POSTGRES_URL, - dbPrimaryPoolSize: maybeParseInt(process.env.DB_PRIMARY_POOL_SIZE), - dbReplicaPostgresUrls: process.env.DB_REPLICA_POSTGRES_URLS - ? process.env.DB_REPLICA_POSTGRES_URLS.split(',') - : undefined, - dbReplicaTags: { - '*': getTagIdxs(process.env.DB_REPLICA_TAGS_ANY), // e.g. DB_REPLICA_TAGS_ANY=0,1 - timeline: getTagIdxs(process.env.DB_REPLICA_TAGS_TIMELINE), - feed: getTagIdxs(process.env.DB_REPLICA_TAGS_FEED), - search: getTagIdxs(process.env.DB_REPLICA_TAGS_SEARCH), - thread: getTagIdxs(process.env.DB_REPLICA_TAGS_THREAD), - }, - dbPostgresSchema: process.env.DB_POSTGRES_SCHEMA || undefined, - dbPoolSize: maybeParseInt(process.env.DB_POOL_SIZE), - dbPoolMaxUses: maybeParseInt(process.env.DB_POOL_MAX_USES), - dbPoolIdleTimeoutMs: maybeParseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS), - serviceSigningKey: process.env.SERVICE_SIGNING_KEY, - publicUrl: process.env.PUBLIC_URL, - didPlcUrl: process.env.DID_PLC_URL, - imgUriSalt: process.env.IMG_URI_SALT, - imgUriKey: process.env.IMG_URI_KEY, - imgUriEndpoint: process.env.IMG_URI_ENDPOINT, - blobCacheLocation: process.env.BLOB_CACHE_LOC, - bunnyAccessKey: process.env.BUNNY_ACCESS_KEY, - cfDistributionId: process.env.CF_DISTRIBUTION_ID, - feedPublisherDid: process.env.FEED_PUBLISHER_DID, + serviceSigningKey: process.env.BSKY_SERVICE_SIGNING_KEY || undefined, }) -/** - * @param {Record} tags - * @param {number} idx - */ -const getTagsForIdx = (tagMap, idx) => { - const tags = [] - for (const [tag, indexes] of Object.entries(tagMap)) { - if (indexes.includes(idx)) { - tags.push(tag) - } - } - return tags -} - -/** - * @param {string} str - */ -const getTagIdxs = (str) => { - return str ? str.split(',').map((item) => parseInt(item, 10)) : [] -} - const maybeParseInt = (str) => { - const parsed = parseInt(str) - return isNaN(parsed) ? undefined : parsed + if (!str) return + const int = parseInt(str, 10) + if (isNaN(int)) return + return int } const maintainXrpcResource = (span, req) => { diff --git a/services/bsky/daemon.js b/services/bsky/daemon.js deleted file mode 100644 index 38b2fdb59e4..00000000000 --- a/services/bsky/daemon.js +++ /dev/null @@ -1,47 +0,0 @@ -'use strict' /* eslint-disable */ - -require('dd-trace/init') // Only works with commonjs - -// Tracer code above must come before anything else -const { PrimaryDatabase, DaemonConfig, BskyDaemon } = require('@atproto/bsky') - -const main = async () => { - const env = getEnv() - const db = new PrimaryDatabase({ - url: env.dbPostgresUrl, - schema: env.dbPostgresSchema, - poolSize: env.dbPoolSize, - poolMaxUses: env.dbPoolMaxUses, - poolIdleTimeoutMs: env.dbPoolIdleTimeoutMs, - }) - const cfg = DaemonConfig.readEnv({ - version: env.version, - dbPostgresUrl: env.dbPostgresUrl, - dbPostgresSchema: env.dbPostgresSchema, - notificationsDaemonFromDid: env.notificationsDaemonFromDid, - }) - const daemon = BskyDaemon.create({ db, cfg }) - await daemon.start() - process.on('SIGTERM', async () => { - await daemon.destroy() - }) -} - -const getEnv = () => ({ - version: process.env.BSKY_VERSION, - dbPostgresUrl: - process.env.DB_PRIMARY_POSTGRES_URL || process.env.DB_POSTGRES_URL, - dbPostgresSchema: process.env.DB_POSTGRES_SCHEMA || undefined, - dbPoolSize: maybeParseInt(process.env.DB_POOL_SIZE), - dbPoolMaxUses: maybeParseInt(process.env.DB_POOL_MAX_USES), - dbPoolIdleTimeoutMs: maybeParseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS), - notificationsDaemonFromDid: - process.env.BSKY_NOTIFS_DAEMON_FROM_DID || undefined, -}) - -const maybeParseInt = (str) => { - const parsed = parseInt(str) - return isNaN(parsed) ? undefined : parsed -} - -main() diff --git a/services/bsky/indexer.js b/services/bsky/indexer.js deleted file mode 100644 index c7327339ff2..00000000000 --- a/services/bsky/indexer.js +++ /dev/null @@ -1,127 +0,0 @@ -'use strict' /* eslint-disable */ - -require('dd-trace/init') // Only works with commonjs - -// Tracer code above must come before anything else -const { CloudfrontInvalidator, BunnyInvalidator } = require('@atproto/aws') -const { - IndexerConfig, - BskyIndexer, - Redis, - PrimaryDatabase, -} = require('@atproto/bsky') - -const main = async () => { - const env = getEnv() - const db = new PrimaryDatabase({ - url: env.dbPostgresUrl, - schema: env.dbPostgresSchema, - poolSize: env.dbPoolSize, - poolMaxUses: env.dbPoolMaxUses, - poolIdleTimeoutMs: env.dbPoolIdleTimeoutMs, - }) - const cfg = IndexerConfig.readEnv({ - version: env.version, - dbPostgresUrl: env.dbPostgresUrl, - dbPostgresSchema: env.dbPostgresSchema, - }) - - // configure zero, one, or both image invalidators - let imgInvalidator - const bunnyInvalidator = env.bunnyAccessKey - ? new BunnyInvalidator({ - accessKey: env.bunnyAccessKey, - urlPrefix: cfg.imgUriEndpoint, - }) - : undefined - const cfInvalidator = env.cfDistributionId - ? new CloudfrontInvalidator({ - distributionId: env.cfDistributionId, - pathPrefix: cfg.imgUriEndpoint && new URL(cfg.imgUriEndpoint).pathname, - }) - : undefined - if (bunnyInvalidator && imgInvalidator) { - imgInvalidator = new MultiImageInvalidator([ - bunnyInvalidator, - imgInvalidator, - ]) - } else if (bunnyInvalidator) { - imgInvalidator = bunnyInvalidator - } else if (cfInvalidator) { - imgInvalidator = cfInvalidator - } - - const redis = new Redis( - cfg.redisSentinelName - ? { - sentinel: cfg.redisSentinelName, - hosts: cfg.redisSentinelHosts, - password: cfg.redisPassword, - } - : { - host: cfg.redisHost, - password: cfg.redisPassword, - }, - ) - - const redisCache = new Redis( - cfg.redisSentinelName - ? { - sentinel: cfg.redisSentinelName, - hosts: cfg.redisSentinelHosts, - password: cfg.redisPassword, - db: 1, - } - : { - host: cfg.redisHost, - password: cfg.redisPassword, - db: 1, - }, - ) - - const indexer = BskyIndexer.create({ - db, - redis, - redisCache, - cfg, - imgInvalidator, - }) - await indexer.start() - process.on('SIGTERM', async () => { - await indexer.destroy() - }) -} - -// Also accepts the following in readEnv(): -// - REDIS_HOST -// - REDIS_SENTINEL_NAME -// - REDIS_SENTINEL_HOSTS -// - REDIS_PASSWORD -// - DID_PLC_URL -// - DID_CACHE_STALE_TTL -// - DID_CACHE_MAX_TTL -// - LABELER_DID -// - HIVE_API_KEY -// - INDEXER_PARTITION_IDS -// - INDEXER_PARTITION_BATCH_SIZE -// - INDEXER_CONCURRENCY -// - INDEXER_SUB_LOCK_ID -const getEnv = () => ({ - version: process.env.BSKY_VERSION, - dbPostgresUrl: - process.env.DB_PRIMARY_POSTGRES_URL || process.env.DB_POSTGRES_URL, - dbPostgresSchema: process.env.DB_POSTGRES_SCHEMA || undefined, - dbPoolSize: maybeParseInt(process.env.DB_POOL_SIZE), - dbPoolMaxUses: maybeParseInt(process.env.DB_POOL_MAX_USES), - dbPoolIdleTimeoutMs: maybeParseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS), - bunnyAccessKey: process.env.BUNNY_ACCESS_KEY, - cfDistributionId: process.env.CF_DISTRIBUTION_ID, - imgUriEndpoint: process.env.IMG_URI_ENDPOINT, -}) - -const maybeParseInt = (str) => { - const parsed = parseInt(str) - return isNaN(parsed) ? undefined : parsed -} - -main() diff --git a/services/bsky/ingester.js b/services/bsky/ingester.js deleted file mode 100644 index 19c33ea1067..00000000000 --- a/services/bsky/ingester.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict' /* eslint-disable */ - -require('dd-trace/init') // Only works with commonjs - -// Tracer code above must come before anything else -const { - PrimaryDatabase, - IngesterConfig, - BskyIngester, - Redis, -} = require('@atproto/bsky') - -const main = async () => { - const env = getEnv() - // No migration: ingester only uses pg for a lock - const db = new PrimaryDatabase({ - url: env.dbPostgresUrl, - schema: env.dbPostgresSchema, - poolSize: env.dbPoolSize, - poolMaxUses: env.dbPoolMaxUses, - poolIdleTimeoutMs: env.dbPoolIdleTimeoutMs, - }) - const cfg = IngesterConfig.readEnv({ - version: env.version, - dbPostgresUrl: env.dbPostgresUrl, - dbPostgresSchema: env.dbPostgresSchema, - repoProvider: env.repoProvider, - ingesterSubLockId: env.subLockId, - }) - const redis = new Redis( - cfg.redisSentinelName - ? { - sentinel: cfg.redisSentinelName, - hosts: cfg.redisSentinelHosts, - password: cfg.redisPassword, - } - : { - host: cfg.redisHost, - password: cfg.redisPassword, - }, - ) - const ingester = BskyIngester.create({ db, redis, cfg }) - await ingester.start() - process.on('SIGTERM', async () => { - await ingester.destroy() - }) -} - -// Also accepts the following in readEnv(): -// - REDIS_HOST -// - REDIS_SENTINEL_NAME -// - REDIS_SENTINEL_HOSTS -// - REDIS_PASSWORD -// - REPO_PROVIDER -// - INGESTER_PARTITION_COUNT -// - INGESTER_MAX_ITEMS -// - INGESTER_CHECK_ITEMS_EVERY_N -// - INGESTER_INITIAL_CURSOR -// - INGESTER_SUB_LOCK_ID -const getEnv = () => ({ - version: process.env.BSKY_VERSION, - dbPostgresUrl: - process.env.DB_PRIMARY_POSTGRES_URL || process.env.DB_POSTGRES_URL, - dbPostgresSchema: process.env.DB_POSTGRES_SCHEMA || undefined, - dbPoolSize: maybeParseInt(process.env.DB_POOL_SIZE), - dbPoolMaxUses: maybeParseInt(process.env.DB_POOL_MAX_USES), - dbPoolIdleTimeoutMs: maybeParseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS), -}) - -const maybeParseInt = (str) => { - const parsed = parseInt(str) - return isNaN(parsed) ? undefined : parsed -} - -main() diff --git a/services/bsky/package.json b/services/bsky/package.json index 65de10674dc..c1feff5b40b 100644 --- a/services/bsky/package.json +++ b/services/bsky/package.json @@ -2,8 +2,8 @@ "name": "bsky-app-view-service", "private": true, "dependencies": { - "@atproto/aws": "workspace:^", "@atproto/bsky": "workspace:^", + "@atproto/crypto": "workspace:^", "dd-trace": "3.13.2" } }