A decentralized music tracking and discovery platform built on AT Protocol 🎵

Add cursor pagination to follow graph APIs

Order follow queries by follows.createdAt DESC and apply a limit
(default 50). Use a createdAt timestamp cursor (lt) and return the next
cursor in responses.

+118 -20
+3 -1
apps/api/src/xrpc/app/rocksky/graph/followAccount.ts
··· 1 1 import { TID } from "@atproto/common"; 2 2 import type { HandlerAuth } from "@atproto/xrpc-server"; 3 3 import type { Context } from "context"; 4 - import { and, eq } from "drizzle-orm"; 4 + import { and, eq, desc } from "drizzle-orm"; 5 5 import { Effect, pipe } from "effect"; 6 6 import type { Server } from "lexicon"; 7 7 import type { ProfileViewBasic } from "lexicon/types/app/rocksky/actor/defs"; ··· 114 114 tables.users, 115 115 eq(tables.users.did, tables.follows.follower_did), 116 116 ) 117 + .orderBy(desc(tables.follows.createdAt)) 118 + .limit(50) 117 119 .execute() 118 120 .then((rows) => rows.map(({ users }) => users)), 119 121 ]);
+37 -4
apps/api/src/xrpc/app/rocksky/graph/getFollowers.ts
··· 1 1 import type { Context } from "context"; 2 - import { eq, or, sql } from "drizzle-orm"; 2 + import { eq, desc, and, lt } from "drizzle-orm"; 3 3 import { Effect, pipe } from "effect"; 4 4 import type { Server } from "lexicon"; 5 5 import type { QueryParams } from "lexicon/types/app/rocksky/graph/getFollowers"; ··· 40 40 }: { 41 41 params: QueryParams; 42 42 ctx: Context; 43 - }): Effect.Effect<[SelectUser | undefined, SelectUser[]], Error> => { 43 + }): Effect.Effect< 44 + [SelectUser | undefined, SelectUser[], string | undefined], 45 + Error 46 + > => { 44 47 return Effect.tryPromise({ 45 48 try: () => 46 49 Promise.all([ ··· 53 56 ctx.db 54 57 .select() 55 58 .from(tables.follows) 56 - .where(eq(tables.follows.subject_did, params.actor)) 59 + .where( 60 + params.cursor 61 + ? and( 62 + lt(tables.follows.createdAt, new Date(params.cursor)), 63 + eq(tables.follows.subject_did, params.actor), 64 + ) 65 + : eq(tables.follows.subject_did, params.actor), 66 + ) 57 67 .leftJoin( 58 68 tables.users, 59 69 eq(tables.users.did, tables.follows.follower_did), 60 70 ) 71 + .orderBy(desc(tables.follows.createdAt)) 72 + .limit(params.limit ?? 50) 61 73 .execute() 62 74 .then((rows) => rows.map(({ users }) => users)), 75 + ctx.db 76 + .select() 77 + .from(tables.follows) 78 + .where( 79 + params.cursor 80 + ? and( 81 + lt(tables.follows.createdAt, new Date(params.cursor)), 82 + eq(tables.follows.subject_did, params.actor), 83 + ) 84 + : eq(tables.follows.subject_did, params.actor), 85 + ) 86 + .orderBy(desc(tables.follows.createdAt)) 87 + .limit(params.limit ?? 50) 88 + .execute() 89 + .then((rows) => 90 + rows.length > 0 91 + ? rows[rows.length - 1]?.createdAt.getTime().toString() 92 + : undefined, 93 + ), 63 94 ]), 64 95 catch: (error) => new Error(`Failed to retrieve user followers: ${error}`), 65 96 }); 66 97 }; 67 98 68 - const presentation = ([user, followers]: [ 99 + const presentation = ([user, followers, cursor]: [ 69 100 SelectUser | undefined, 70 101 SelectUser[], 102 + string | undefined, 71 103 ]): Effect.Effect< 72 104 { subject: ProfileViewBasic; followers: ProfileViewBasic[] }, 73 105 never ··· 91 123 createdAt: follower.createdAt.toISOString(), 92 124 updatedAt: follower.updatedAt.toISOString(), 93 125 })), 126 + cursor, 94 127 })); 95 128 };
+39 -4
apps/api/src/xrpc/app/rocksky/graph/getFollows.ts
··· 1 1 import type { Context } from "context"; 2 - import { eq, or, sql } from "drizzle-orm"; 2 + import { eq, desc, and, lt } from "drizzle-orm"; 3 3 import { Effect, pipe } from "effect"; 4 4 import type { Server } from "lexicon"; 5 5 import type { QueryParams } from "lexicon/types/app/rocksky/graph/getFollowers"; ··· 12 12 pipe( 13 13 { params, ctx }, 14 14 retrieve, 15 - Effect.flatMap(([user, follows]) => presentation(user, follows)), 15 + Effect.flatMap(([user, follows, cursor]) => 16 + presentation(user, follows, cursor), 17 + ), 16 18 Effect.retry({ times: 3 }), 17 19 Effect.timeout("120 seconds"), 18 20 Effect.catchAll((err) => { ··· 40 42 }: { 41 43 params: QueryParams; 42 44 ctx: Context; 43 - }): Effect.Effect<[SelectUser | undefined, SelectUser[]], Error> => { 45 + }): Effect.Effect< 46 + [SelectUser | undefined, SelectUser[], string | undefined], 47 + Error 48 + > => { 44 49 return Effect.tryPromise({ 45 50 try: () => 46 51 Promise.all([ ··· 53 58 ctx.db 54 59 .select() 55 60 .from(tables.follows) 56 - .where(eq(tables.follows.follower_did, params.actor)) 61 + .where( 62 + params.cursor 63 + ? and( 64 + lt(tables.follows.createdAt, new Date(params.cursor)), 65 + eq(tables.follows.follower_did, params.actor), 66 + ) 67 + : eq(tables.follows.follower_did, params.actor), 68 + ) 57 69 .leftJoin( 58 70 tables.users, 59 71 eq(tables.users.did, tables.follows.follower_did), 60 72 ) 73 + .orderBy(desc(tables.follows.createdAt)) 74 + .limit(params.limit ?? 50) 61 75 .execute() 62 76 .then((rows) => rows.map(({ users }) => users)), 77 + ctx.db 78 + .select() 79 + .from(tables.follows) 80 + .where( 81 + params.cursor 82 + ? and( 83 + lt(tables.follows.createdAt, new Date(params.cursor)), 84 + eq(tables.follows.follower_did, params.actor), 85 + ) 86 + : eq(tables.follows.follower_did, params.actor), 87 + ) 88 + .orderBy(desc(tables.follows.createdAt)) 89 + .limit(params.limit ?? 50) 90 + .execute() 91 + .then((rows) => 92 + rows.length > 0 93 + ? rows[rows.length - 1]?.createdAt.getTime().toString() 94 + : undefined, 95 + ), 63 96 ]), 64 97 catch: (error) => new Error(`Failed to retrieve user follows: ${error}`), 65 98 }); ··· 68 101 const presentation = ( 69 102 user: SelectUser | undefined, 70 103 follows: SelectUser[], 104 + cursor: string | undefined, 71 105 ): Effect.Effect< 72 106 { subject: ProfileViewBasic; follows: ProfileViewBasic[] }, 73 107 never ··· 91 125 createdAt: follow.createdAt.toISOString(), 92 126 updatedAt: follow.updatedAt.toISOString(), 93 127 })), 128 + cursor, 94 129 })); 95 130 };
+32 -10
apps/api/src/xrpc/app/rocksky/graph/getKnownFollowers.ts
··· 1 1 import type { Context } from "context"; 2 - import { and, eq, sql } from "drizzle-orm"; 2 + import { and, eq, sql, desc, lt } from "drizzle-orm"; 3 3 import { Effect, pipe } from "effect"; 4 4 import type { Server } from "lexicon"; 5 5 import type { QueryParams } from "lexicon/types/app/rocksky/graph/getKnownFollowers"; ··· 45 45 params: QueryParams; 46 46 ctx: Context; 47 47 viewerDid?: string; 48 - }): Effect.Effect<[SelectUser | undefined, SelectUser[]], Error> => { 48 + }): Effect.Effect< 49 + [SelectUser | undefined, SelectUser[], string | undefined], 50 + Error 51 + > => { 49 52 if (!viewerDid) { 50 - return Effect.succeed([undefined, []]); 53 + return Effect.succeed([undefined, [], undefined]); 51 54 } 52 55 53 56 return Effect.tryPromise({ ··· 66 69 eq(tables.users.did, tables.follows.follower_did), 67 70 ) 68 71 .where( 69 - and( 70 - eq(tables.follows.subject_did, params.actor), 71 - sql`EXISTS ( 72 + params.cursor 73 + ? and( 74 + lt(tables.follows.createdAt, new Date(params.cursor)), 75 + eq(tables.follows.subject_did, params.actor), 76 + sql`EXISTS ( 72 77 SELECT 1 FROM ${tables.follows} f2 73 78 WHERE f2.subject_did = ${tables.users.did} 74 79 AND f2.follower_did = ${viewerDid} 75 80 )`, 76 - ), 81 + ) 82 + : and( 83 + eq(tables.follows.subject_did, params.actor), 84 + sql`EXISTS ( 85 + SELECT 1 FROM ${tables.follows} f2 86 + WHERE f2.subject_did = ${tables.users.did} 87 + AND f2.follower_did = ${viewerDid} 88 + )`, 89 + ), 77 90 ) 78 - .limit(params.limit ?? 100) 91 + .orderBy(desc(tables.follows.createdAt)) 92 + .limit(params.limit ?? 50) 79 93 .execute(); 80 - return [user, knownFollowers.map((row) => row.users)]; 94 + const cursor = 95 + knownFollowers.length > 0 96 + ? knownFollowers[knownFollowers.length - 1].follows.createdAt 97 + .getTime() 98 + .toString() 99 + : undefined; 100 + return [user, knownFollowers.map((row) => row.users), cursor]; 81 101 }, 82 102 catch: (error) => new Error(`Failed to retrieve known followers: ${error}`), 83 103 }); 84 104 }; 85 105 86 - const presentation = ([user, followers]: [ 106 + const presentation = ([user, followers, cursor]: [ 87 107 SelectUser | undefined, 88 108 SelectUser[], 109 + string | undefined, 89 110 ]): Effect.Effect< 90 111 { subject: ProfileViewBasic; followers: ProfileViewBasic[] }, 91 112 never ··· 109 130 createdAt: follower.createdAt.toISOString(), 110 131 updatedAt: follower.updatedAt.toISOString(), 111 132 })), 133 + cursor, 112 134 })); 113 135 };
+7 -1
apps/api/src/xrpc/app/rocksky/graph/unfollowAccount.ts
··· 1 1 import type { HandlerAuth } from "@atproto/xrpc-server"; 2 2 import type { Context } from "context"; 3 - import { and, eq } from "drizzle-orm"; 3 + import { and, eq, desc } from "drizzle-orm"; 4 4 import { Effect, pipe } from "effect"; 5 5 import type { Server } from "lexicon"; 6 6 import type { ProfileViewBasic } from "lexicon/types/app/rocksky/actor/defs"; ··· 113 113 tables.users, 114 114 eq(tables.users.did, tables.follows.follower_did), 115 115 ) 116 + .orderBy(desc(tables.follows.createdAt)) 117 + .limit(50) 116 118 .execute() 117 119 .then((rows) => rows.map(({ users }) => users)), 118 120 ]); ··· 147 149 createdAt: follower.createdAt.toISOString(), 148 150 updatedAt: follower.updatedAt.toISOString(), 149 151 })), 152 + cursor: 153 + followers.length === 50 154 + ? followers[49].createdAt.getTime().toString() 155 + : undefined, 150 156 })); 151 157 }; 152 158