Testing implementation for private data in ATProto with ATPKeyserver and ATCute tools

new content.getFeed method in appview

+203 -3
+46
lexicons/app/wafrn/content/getFeed.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.content.getFeed", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a feed of posts from accounts the viewer follows", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["viewer"], 11 + "properties": { 12 + "viewer": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "DID of the viewer requesting their following feed" 16 + }, 17 + "limit": { 18 + "type": "integer", 19 + "default": 20, 20 + "minimum": 1, 21 + "maximum": 100 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "application/json", 27 + "schema": { 28 + "type": "object", 29 + "required": ["feed"], 30 + "properties": { 31 + "feed": { 32 + "type": "array", 33 + "items": { 34 + "type": "union", 35 + "refs": [ 36 + "app.wafrn.content.defs#publicPostView", 37 + "app.wafrn.content.defs#privatePostView" 38 + ] 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+1
packages/lexicon/index.ts
··· 5 5 export * as AppWafrnActorProfile from "./types/app/wafrn/actor/profile.js"; 6 6 export * as AppWafrnContentDefs from "./types/app/wafrn/content/defs.js"; 7 7 export * as AppWafrnContentDeletePost from "./types/app/wafrn/content/deletePost.js"; 8 + export * as AppWafrnContentGetFeed from "./types/app/wafrn/content/getFeed.js"; 8 9 export * as AppWafrnContentInbox from "./types/app/wafrn/content/inbox.js"; 9 10 export * as AppWafrnContentPrivatePost from "./types/app/wafrn/content/privatePost.js"; 10 11 export * as AppWafrnContentPublicPost from "./types/app/wafrn/content/publicPost.js";
+52
packages/lexicon/types/app/wafrn/content/getFeed.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as AppWafrnContentDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("app.wafrn.content.getFeed", { 7 + params: /*#__PURE__*/ v.object({ 8 + /** 9 + * @minimum 1 10 + * @maximum 100 11 + * @default 20 12 + */ 13 + limit: /*#__PURE__*/ v.optional( 14 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 15 + /*#__PURE__*/ v.integerRange(1, 100), 16 + ]), 17 + 20, 18 + ), 19 + /** 20 + * DID of the viewer requesting their following feed 21 + */ 22 + viewer: /*#__PURE__*/ v.didString(), 23 + }), 24 + output: { 25 + type: "lex", 26 + schema: /*#__PURE__*/ v.object({ 27 + get feed() { 28 + return /*#__PURE__*/ v.array( 29 + /*#__PURE__*/ v.variant([ 30 + AppWafrnContentDefs.privatePostViewSchema, 31 + AppWafrnContentDefs.publicPostViewSchema, 32 + ]), 33 + ); 34 + }, 35 + }), 36 + }, 37 + }); 38 + 39 + type main$schematype = typeof _mainSchema; 40 + 41 + export interface mainSchema extends main$schematype {} 42 + 43 + export const mainSchema = _mainSchema as mainSchema; 44 + 45 + export interface $params extends v.InferInput<mainSchema["params"]> {} 46 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 47 + 48 + declare module "@atcute/lexicons/ambient" { 49 + interface XRPCQueries { 50 + "app.wafrn.content.getFeed": mainSchema; 51 + } 52 + }
+92 -1
packages/server/src/lib/feed.ts
··· 7 7 limit: number 8 8 } 9 9 10 - export async function getFeed({ did, limit }: FeedParams) { 10 + type FollowingFeedParams = { 11 + viewer: Did 12 + limit: number 13 + } 14 + 15 + export async function getActorFeed({ did, limit }: FeedParams) { 11 16 const [publicPostRows, privatePostRows] = await Promise.all([ 12 17 db 13 18 .selectFrom('public_posts') ··· 78 83 79 84 return posts 80 85 } 86 + 87 + /** 88 + * Get feed of posts from accounts the viewer follows. 89 + * Uses efficient JOINs following Kysely best practices. 90 + */ 91 + export async function getFollowingFeed({ 92 + viewer, 93 + limit 94 + }: FollowingFeedParams) { 95 + // Step 1: Fetch posts from followed accounts using JOINs (efficient!) 96 + // Use 2x limit to account for mixed public/private posts 97 + const [publicPostRows, privatePostRows] = await Promise.all([ 98 + db 99 + .selectFrom('public_posts') 100 + .innerJoin('follows', 'public_posts.author_did', 'follows.followee_did') 101 + .selectAll('public_posts') 102 + .where('follows.follower_did', '=', viewer) 103 + .orderBy('public_posts.created_at', 'desc') 104 + .limit(limit * 2) 105 + .execute(), 106 + db 107 + .selectFrom('private_posts') 108 + .innerJoin('follows', 'private_posts.author_did', 'follows.followee_did') 109 + .selectAll('private_posts') 110 + .where('follows.follower_did', '=', viewer) 111 + .orderBy('private_posts.created_at', 'desc') 112 + .limit(limit * 2) 113 + .execute() 114 + ]) 115 + 116 + // Step 2: Batch fetch tags for all public posts (avoid N+1 queries) 117 + const publicPostUris = publicPostRows.map((p) => p.uri) 118 + const tagRows = 119 + publicPostUris.length > 0 120 + ? await db 121 + .selectFrom('public_post_tags') 122 + .selectAll() 123 + .where('post_uri', 'in', publicPostUris) 124 + .execute() 125 + : [] 126 + 127 + // Step 3: Group tags by post URI for efficient lookup 128 + const tagsByPostUri = new Map<string, string[]>() 129 + for (const tagRow of tagRows) { 130 + const existing = tagsByPostUri.get(tagRow.post_uri) ?? [] 131 + existing.push(tagRow.tag_name) 132 + tagsByPostUri.set(tagRow.post_uri, existing) 133 + } 134 + 135 + // Step 4: Transform database rows to API response format 136 + const publicPosts = publicPostRows.map( 137 + (p) => 138 + ({ 139 + $type: 'app.wafrn.content.defs#publicPostView', 140 + uri: p.uri as ResourceUri, 141 + content: { 142 + contentHTML: p.content_html, 143 + contentMarkdown: p.content_markdown, 144 + contentWarning: p.content_warning ?? '', 145 + tags: tagsByPostUri.get(p.uri) ?? [] 146 + }, 147 + createdAt: new Date(p.created_at).toISOString(), 148 + updatedAt: new Date(p.updated_at).toISOString() 149 + }) satisfies AppWafrnContentDefs.PublicPostView 150 + ) 151 + 152 + const privatePosts = privatePostRows.map( 153 + (p) => 154 + ({ 155 + $type: 'app.wafrn.content.defs#privatePostView', 156 + uri: p.uri as ResourceUri, 157 + visibility: p.visibility, 158 + keyVersion: p.key_version, 159 + encryptedContent: p.encrypted_content, 160 + createdAt: new Date(p.created_at).toISOString(), 161 + updatedAt: new Date(p.updated_at).toISOString() 162 + }) satisfies AppWafrnContentDefs.PrivatePostView 163 + ) 164 + 165 + // Step 5: Merge and sort by creation time (most recent first) 166 + const posts = [...publicPosts, ...privatePosts].sort((a, b) => { 167 + return Number(a.createdAt < b.createdAt) 168 + }) 169 + 170 + return posts 171 + }
+12 -2
packages/server/src/lib/xrpcServer.ts
··· 4 4 import { 5 5 AppWafrnContentInbox, 6 6 AppWafrnActorGetFeed, 7 + AppWafrnContentGetFeed, 7 8 AppWafrnContentDeletePost, 8 9 AppWafrnGraphDeleteFollow, 9 10 AppWafrnGraphGetFollowers, ··· 25 26 import { getWafrnProfiles } from '@api/lib/profile' 26 27 import { deletePost } from '@api/lib/post' 27 28 import { processInboxItems } from '@api/lib/inbox' 28 - import { getFeed } from './feed' 29 + import { getActorFeed, getFollowingFeed } from './feed' 29 30 30 31 const jwtVerifier = new ServiceJwtVerifier({ 31 32 resolver: didDocResolver, ··· 114 115 115 116 xrpcServer.addQuery(AppWafrnActorGetFeed.mainSchema, { 116 117 async handler({ params }) { 117 - const posts = await getFeed(params) 118 + const posts = await getActorFeed(params) 119 + return json({ 120 + feed: posts 121 + }) 122 + } 123 + }) 124 + 125 + xrpcServer.addQuery(AppWafrnContentGetFeed.mainSchema, { 126 + async handler({ params }) { 127 + const posts = await getFollowingFeed(params) 118 128 return json({ 119 129 feed: posts 120 130 })