See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Remove unused parseGetPostsResponse and tighten filter type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+14 -261
+13 -2
app/lib/atproto/client.ts
··· 5 5 // --------------------------------------------------------------------------- 6 6 7 7 /** 8 + * Valid values for the `filter` parameter of app.bsky.feed.getAuthorFeed. 9 + * Mirrors the AppView lexicon enum so typos fail at compile time. 10 + */ 11 + export type AuthorFeedFilter = 12 + | 'posts_with_replies' 13 + | 'posts_no_replies' 14 + | 'posts_with_media' 15 + | 'posts_and_author_threads' 16 + | 'posts_with_video' 17 + 18 + /** 8 19 * A feed item from app.bsky.feed.getAuthorFeed. 9 20 * The `post` field holds a PostView object. 10 21 */ ··· 173 184 } 174 185 feed: { 175 186 getAuthorFeed: ( 176 - params: { actor: string; cursor?: string; limit?: number; filter?: string }, 187 + params: { actor: string; cursor?: string; limit?: number; filter?: AuthorFeedFilter }, 177 188 opts?: unknown 178 189 ) => Promise<{ 179 190 data: { feed: FeedViewPost[]; cursor?: string } ··· 372 383 did: string, 373 384 cursor?: string, 374 385 limit: number = 100, 375 - filter: string = 'posts_with_replies' 386 + filter: AuthorFeedFilter = 'posts_with_replies' 376 387 ): Promise<{ posts: FeedViewPost[]; cursor?: string }> { 377 388 const { 378 389 value: response,
+1 -1
app/lib/atproto/index.ts
··· 1 1 // Pure parsers 2 2 export { parseAtUri } from './parsers/at_uri.js' 3 3 export { parseJetstreamEvent } from './parsers/jetstream.js' 4 - export { parseGetPostsResponse } from './parsers/get_posts.js' 5 4 export { parseGetAuthorFeedResponse } from './parsers/get_author_feed.js' 6 5 7 6 // Types ··· 24 23 PostView, 25 24 AtprotoRequestEvent, 26 25 AtprotoRequestHook, 26 + AuthorFeedFilter, 27 27 } from './client.js'
-74
app/lib/atproto/parsers/get_posts.ts
··· 1 - import type { PostSnapshot } from '../types.js' 2 - 3 - function isObject(v: unknown): v is Record<string, unknown> { 4 - return typeof v === 'object' && v !== null && !Array.isArray(v) 5 - } 6 - 7 - function getString(obj: Record<string, unknown>, key: string): string { 8 - const v = obj[key] 9 - if (typeof v !== 'string') throw new Error(`Expected string for "${key}", got ${typeof v}`) 10 - return v 11 - } 12 - 13 - function getNumber(obj: Record<string, unknown>, key: string, fallback: number): number { 14 - const v = obj[key] 15 - return typeof v === 'number' ? v : fallback 16 - } 17 - 18 - function getObject(obj: Record<string, unknown>, key: string): Record<string, unknown> { 19 - const v = obj[key] 20 - if (!isObject(v)) throw new Error(`Expected object for "${key}", got ${typeof v}`) 21 - return v 22 - } 23 - 24 - /** 25 - * Parse the response body from `app.bsky.feed.getPosts` into an array of 26 - * PostSnapshots ready for ClickHouse insertion. 27 - * 28 - * @param response - The parsed JSON body (a `{ posts: PostView[] }` object) 29 - * @returns Array of PostSnapshot objects. snapshotTakenAt is set to now(). 30 - * @throws {Error} if the response is malformed or missing required fields. 31 - * 32 - * Spec: §5 (post_snapshots table) and §6 Flow 2 (backfill) 33 - * API: https://docs.bsky.app/docs/api/app-bsky-feed-get-posts 34 - */ 35 - export function parseGetPostsResponse(response: unknown): PostSnapshot[] { 36 - if (!isObject(response)) { 37 - throw new Error(`parseGetPostsResponse: expected an object, got ${typeof response}`) 38 - } 39 - 40 - const posts = response['posts'] 41 - if (!Array.isArray(posts)) { 42 - throw new Error(`parseGetPostsResponse: expected "posts" to be an array, got ${typeof posts}`) 43 - } 44 - 45 - const snapshotTakenAt = new Date() 46 - 47 - return posts.map((post: unknown, i: number) => { 48 - if (!isObject(post)) { 49 - throw new Error(`parseGetPostsResponse: post[${i}] is not an object`) 50 - } 51 - 52 - const postUri = getString(post, 'uri') 53 - const author = getObject(post, 'author') 54 - const postAuthorDid = getString(author, 'did') 55 - const record = getObject(post, 'record') 56 - const postText = getString(record, 'text') 57 - const createdAtStr = getString(record, 'createdAt') 58 - 59 - const snapshotLikes = getNumber(post, 'likeCount', 0) 60 - const snapshotReposts = getNumber(post, 'repostCount', 0) 61 - const snapshotQuotes = getNumber(post, 'quoteCount', 0) 62 - 63 - return { 64 - postUri, 65 - postAuthorDid, 66 - postText, 67 - postCreatedAt: new Date(createdAtStr), 68 - snapshotLikes, 69 - snapshotReposts, 70 - snapshotQuotes, 71 - snapshotTakenAt, 72 - } 73 - }) 74 - }
-184
tests/unit/atproto/get_posts.spec.ts
··· 1 - import { test } from '@japa/runner' 2 - import { parseGetPostsResponse } from '#lib/atproto/index' 3 - 4 - // --------------------------------------------------------------------------- 5 - // Fixtures — shaped like the AppView app.bsky.feed.getPosts response 6 - // --------------------------------------------------------------------------- 7 - 8 - const NOMINAL_RESPONSE = { 9 - posts: [ 10 - { 11 - uri: 'at://did:plc:author1/app.bsky.feed.post/rkey001', 12 - cid: 'bafypostcid001', 13 - author: { 14 - did: 'did:plc:author1', 15 - handle: 'author1.bsky.social', 16 - displayName: 'Author One', 17 - }, 18 - record: { 19 - $type: 'app.bsky.feed.post', 20 - text: 'My first viral post!', 21 - createdAt: '2024-01-15T12:00:00.000Z', 22 - }, 23 - likeCount: 1234, 24 - repostCount: 567, 25 - quoteCount: 89, 26 - replyCount: 42, 27 - indexedAt: '2024-01-15T12:01:00.000Z', 28 - }, 29 - { 30 - uri: 'at://did:plc:author2/app.bsky.feed.post/rkey002', 31 - cid: 'bafypostcid002', 32 - author: { 33 - did: 'did:plc:author2', 34 - handle: 'author2.bsky.social', 35 - }, 36 - record: { 37 - $type: 'app.bsky.feed.post', 38 - text: 'Another popular post with some longer text that goes on for a while.', 39 - createdAt: '2024-02-20T08:30:00.000Z', 40 - }, 41 - likeCount: 42, 42 - repostCount: 7, 43 - quoteCount: 2, 44 - replyCount: 5, 45 - indexedAt: '2024-02-20T08:31:00.000Z', 46 - }, 47 - ], 48 - } 49 - 50 - const EMPTY_RESPONSE = { 51 - posts: [], 52 - } 53 - 54 - const ZERO_ENGAGEMENT_RESPONSE = { 55 - posts: [ 56 - { 57 - uri: 'at://did:plc:newauthor/app.bsky.feed.post/rkeynew', 58 - cid: 'bafypostcidnew', 59 - author: { 60 - did: 'did:plc:newauthor', 61 - handle: 'newauthor.bsky.social', 62 - }, 63 - record: { 64 - $type: 'app.bsky.feed.post', 65 - text: 'My very first post', 66 - createdAt: '2024-03-01T00:00:00.000Z', 67 - }, 68 - likeCount: 0, 69 - repostCount: 0, 70 - quoteCount: 0, 71 - replyCount: 0, 72 - indexedAt: '2024-03-01T00:00:01.000Z', 73 - }, 74 - ], 75 - } 76 - 77 - // Response where likeCount/repostCount/quoteCount are missing (undefined) 78 - // — should default to 0 79 - const MISSING_COUNTS_RESPONSE = { 80 - posts: [ 81 - { 82 - uri: 'at://did:plc:author3/app.bsky.feed.post/rkey003', 83 - cid: 'bafypostcid003', 84 - author: { 85 - did: 'did:plc:author3', 86 - handle: 'author3.bsky.social', 87 - }, 88 - record: { 89 - $type: 'app.bsky.feed.post', 90 - text: 'Post with no counts yet', 91 - createdAt: '2024-04-01T10:00:00.000Z', 92 - }, 93 - // likeCount, repostCount, quoteCount intentionally omitted 94 - indexedAt: '2024-04-01T10:00:01.000Z', 95 - }, 96 - ], 97 - } 98 - 99 - // --------------------------------------------------------------------------- 100 - // Tests 101 - // --------------------------------------------------------------------------- 102 - 103 - test.group('parseGetPostsResponse', () => { 104 - test('parses a nominal response with multiple posts', ({ assert }) => { 105 - const before = new Date() 106 - const result = parseGetPostsResponse(NOMINAL_RESPONSE) 107 - const after = new Date() 108 - 109 - assert.equal(result.length, 2) 110 - 111 - const first = result[0] 112 - assert.equal(first.postUri, 'at://did:plc:author1/app.bsky.feed.post/rkey001') 113 - assert.equal(first.postAuthorDid, 'did:plc:author1') 114 - assert.equal(first.postText, 'My first viral post!') 115 - assert.instanceOf(first.postCreatedAt, Date) 116 - assert.equal(first.postCreatedAt.toISOString(), '2024-01-15T12:00:00.000Z') 117 - assert.equal(first.snapshotLikes, 1234) 118 - assert.equal(first.snapshotReposts, 567) 119 - assert.equal(first.snapshotQuotes, 89) 120 - assert.instanceOf(first.snapshotTakenAt, Date) 121 - // snapshotTakenAt should be "now" at parse time 122 - assert.isAtLeast(first.snapshotTakenAt.getTime(), before.getTime()) 123 - assert.isAtMost(first.snapshotTakenAt.getTime(), after.getTime()) 124 - 125 - const second = result[1] 126 - assert.equal(second.postUri, 'at://did:plc:author2/app.bsky.feed.post/rkey002') 127 - assert.equal(second.postAuthorDid, 'did:plc:author2') 128 - assert.equal(second.snapshotLikes, 42) 129 - assert.equal(second.snapshotReposts, 7) 130 - assert.equal(second.snapshotQuotes, 2) 131 - }) 132 - 133 - test('returns empty array for empty response', ({ assert }) => { 134 - const result = parseGetPostsResponse(EMPTY_RESPONSE) 135 - assert.deepEqual(result, []) 136 - }) 137 - 138 - test('handles posts with zero engagement counts', ({ assert }) => { 139 - const result = parseGetPostsResponse(ZERO_ENGAGEMENT_RESPONSE) 140 - 141 - assert.equal(result.length, 1) 142 - const post = result[0] 143 - assert.equal(post.snapshotLikes, 0) 144 - assert.equal(post.snapshotReposts, 0) 145 - assert.equal(post.snapshotQuotes, 0) 146 - }) 147 - 148 - test('defaults missing count fields to 0', ({ assert }) => { 149 - const result = parseGetPostsResponse(MISSING_COUNTS_RESPONSE) 150 - 151 - assert.equal(result.length, 1) 152 - const post = result[0] 153 - assert.equal(post.snapshotLikes, 0) 154 - assert.equal(post.snapshotReposts, 0) 155 - assert.equal(post.snapshotQuotes, 0) 156 - }) 157 - 158 - test('snapshotTakenAt is set to now (not the post createdAt)', ({ assert }) => { 159 - const before = new Date() 160 - const result = parseGetPostsResponse(NOMINAL_RESPONSE) 161 - const after = new Date() 162 - 163 - for (const post of result) { 164 - assert.isAtLeast(post.snapshotTakenAt.getTime(), before.getTime()) 165 - assert.isAtMost(post.snapshotTakenAt.getTime(), after.getTime()) 166 - // snapshotTakenAt is definitely not the post createdAt (which is years ago) 167 - assert.notEqual(post.snapshotTakenAt.getTime(), post.postCreatedAt.getTime()) 168 - } 169 - }) 170 - 171 - test('throws on non-object input', ({ assert }) => { 172 - assert.throws(() => parseGetPostsResponse(null)) 173 - assert.throws(() => parseGetPostsResponse('not an object')) 174 - assert.throws(() => parseGetPostsResponse(42)) 175 - }) 176 - 177 - test('throws when posts field is missing', ({ assert }) => { 178 - assert.throws(() => parseGetPostsResponse({ other: 'data' })) 179 - }) 180 - 181 - test('throws when posts is not an array', ({ assert }) => { 182 - assert.throws(() => parseGetPostsResponse({ posts: 'not an array' })) 183 - }) 184 - })