grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

feat: add totalCount and viewer state to getFollowers/getFollowing

Add COUNT(*) query to return total follower/following count in responses.
Add viewer param and viewerState to lexicons and handler output for
showing follow relationship in list views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+73 -17
+1 -1
hatk.generated.client.ts
··· 3 3 // to avoid pulling in server-only dependencies. 4 4 export type { XrpcSchema } from './hatk.generated.ts' 5 5 import type { XrpcSchema } from './hatk.generated.ts' 6 - export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, RepoRef, LabelDefinition, LabelLocale, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, FollowingItem, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 6 + export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, RepoRef, LabelDefinition, LabelLocale, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 7 7 8 8 const _procedures = new Set(['dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.unspecced.deleteGallery']) 9 9 const _blobInputs = new Set(['dev.hatk.uploadBlob'])
+4 -2
hatk.generated.ts
··· 62 62 const deleteGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.deleteGallery","defs":{"main":{"type":"procedure","description":"Delete a gallery and all associated records (items, photos, EXIF, favorites, comments).","input":{"encoding":"application/json","schema":{"type":"object","required":["rkey"],"properties":{"rkey":{"type":"string","description":"Record key of the gallery to delete."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const 63 63 const getActorProfileLex = {"lexicon":1,"id":"social.grain.unspecced.getActorProfile","defs":{"main":{"type":"query","description":"Get an actor's profile with gallery stats and follow relationships.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"}}},"output":{"encoding":"application/json","schema":{"type":"ref","ref":"social.grain.actor.defs#profileViewDetailed"}}}}} as const 64 64 const getCamerasLex = {"lexicon":1,"id":"social.grain.unspecced.getCameras","defs":{"main":{"type":"query","description":"Get top cameras by photo count.","output":{"encoding":"application/json","schema":{"type":"object","properties":{"cameras":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getCameras#cameraItem"}}}}}},"cameraItem":{"type":"object","required":["camera","photoCount"],"properties":{"camera":{"type":"string"},"photoCount":{"type":"integer"}}}}} as const 65 - const getFollowersLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowers","defs":{"main":{"type":"query","description":"Get followers for a given user.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getFollowers#followerItem"}},"cursor":{"type":"string"}}}}},"followerItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"}}}}} as const 66 - const getFollowingLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowing","defs":{"main":{"type":"query","description":"Get users that a given user follows.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getFollowing#followingItem"}},"cursor":{"type":"string"}}}}},"followingItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"}}}}} as const 65 + const getFollowersLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowers","defs":{"main":{"type":"query","description":"Get followers for a given user.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"totalCount":{"type":"integer"},"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getFollowers#followerItem"}},"cursor":{"type":"string"}}}}},"followerItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"},"viewer":{"type":"ref","ref":"social.grain.unspecced.getFollowers#viewerState"}}},"viewerState":{"type":"object","properties":{"following":{"type":"string","format":"at-uri"}}}}} as const 66 + const getFollowingLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowing","defs":{"main":{"type":"query","description":"Get users that a given user follows.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"totalCount":{"type":"integer"},"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getFollowing#followingItem"}},"cursor":{"type":"string"}}}}},"followingItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"},"viewer":{"type":"ref","ref":"social.grain.unspecced.getFollowing#viewerState"}}},"viewerState":{"type":"object","properties":{"following":{"type":"string","format":"at-uri"}}}}} as const 67 67 const getGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.getGallery","defs":{"main":{"type":"query","description":"Get a single gallery view by AT URI.","parameters":{"type":"params","required":["gallery"],"properties":{"gallery":{"type":"string","format":"at-uri","description":"The gallery AT URI."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["gallery"],"properties":{"gallery":{"type":"ref","ref":"social.grain.gallery.defs#galleryView"}}}}}}} as const 68 68 const getGalleryThreadLex = {"lexicon":1,"id":"social.grain.unspecced.getGalleryThread","defs":{"main":{"type":"query","description":"Get comments for a gallery, sorted oldest-first with author profiles.","parameters":{"type":"params","required":["gallery"],"properties":{"gallery":{"type":"string","format":"at-uri","description":"The gallery URI to fetch comments for."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["comments"],"properties":{"comments":{"type":"array","items":{"type":"ref","ref":"social.grain.comment.defs#commentView"}},"cursor":{"type":"string"},"totalCount":{"type":"integer"}}}}}}} as const 69 69 const getKnownFollowersLex = {"lexicon":1,"id":"social.grain.unspecced.getKnownFollowers","defs":{"main":{"type":"query","description":"Get followers of a given actor that the viewer also follows.","parameters":{"type":"params","required":["actor","viewer"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":50,"default":50}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getKnownFollowers#followerItem"}}}}}},"followerItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"}}}}} as const ··· 352 352 export type StoryView = Prettify<LexDef<typeof grainStoryDefsLex, 'storyView', Registry>> 353 353 export type CameraItem = Prettify<LexDef<typeof getCamerasLex, 'cameraItem', Registry>> 354 354 export type GetFollowersFollowerItem = Prettify<LexDef<typeof getFollowersLex, 'followerItem', Registry>> 355 + export type GetFollowersViewerState = Prettify<LexDef<typeof getFollowersLex, 'viewerState', Registry>> 355 356 export type FollowingItem = Prettify<LexDef<typeof getFollowingLex, 'followingItem', Registry>> 357 + export type GetFollowingViewerState = Prettify<LexDef<typeof getFollowingLex, 'viewerState', Registry>> 356 358 export type GetKnownFollowersFollowerItem = Prettify<LexDef<typeof getKnownFollowersLex, 'followerItem', Registry>> 357 359 export type LocationItem = Prettify<LexDef<typeof getLocationsLex, 'locationItem', Registry>> 358 360 export type NotificationItem = Prettify<LexDef<typeof getNotificationsLex, 'notificationItem', Registry>>
+10 -1
lexicons/social/grain/unspecced/getFollowers.json
··· 10 10 "required": ["actor"], 11 11 "properties": { 12 12 "actor": { "type": "string", "format": "did" }, 13 + "viewer": { "type": "string", "format": "did" }, 13 14 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, 14 15 "cursor": { "type": "string" } 15 16 } ··· 19 20 "schema": { 20 21 "type": "object", 21 22 "properties": { 23 + "totalCount": { "type": "integer" }, 22 24 "items": { 23 25 "type": "array", 24 26 "items": { "type": "ref", "ref": "social.grain.unspecced.getFollowers#followerItem" } ··· 36 38 "handle": { "type": "string" }, 37 39 "displayName": { "type": "string" }, 38 40 "description": { "type": "string" }, 39 - "avatar": { "type": "string" } 41 + "avatar": { "type": "string" }, 42 + "viewer": { "type": "ref", "ref": "social.grain.unspecced.getFollowers#viewerState" } 43 + } 44 + }, 45 + "viewerState": { 46 + "type": "object", 47 + "properties": { 48 + "following": { "type": "string", "format": "at-uri" } 40 49 } 41 50 } 42 51 }
+10 -1
lexicons/social/grain/unspecced/getFollowing.json
··· 10 10 "required": ["actor"], 11 11 "properties": { 12 12 "actor": { "type": "string", "format": "did" }, 13 + "viewer": { "type": "string", "format": "did" }, 13 14 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, 14 15 "cursor": { "type": "string" } 15 16 } ··· 19 20 "schema": { 20 21 "type": "object", 21 22 "properties": { 23 + "totalCount": { "type": "integer" }, 22 24 "items": { 23 25 "type": "array", 24 26 "items": { "type": "ref", "ref": "social.grain.unspecced.getFollowing#followingItem" } ··· 36 38 "handle": { "type": "string" }, 37 39 "displayName": { "type": "string" }, 38 40 "description": { "type": "string" }, 39 - "avatar": { "type": "string" } 41 + "avatar": { "type": "string" }, 42 + "viewer": { "type": "ref", "ref": "social.grain.unspecced.getFollowing#viewerState" } 43 + } 44 + }, 45 + "viewerState": { 46 + "type": "object", 47 + "properties": { 48 + "following": { "type": "string", "format": "at-uri" } 40 49 } 41 50 } 42 51 }
+24 -6
server/xrpc/getFollowers.ts
··· 2 2 3 3 export default defineQuery("social.grain.unspecced.getFollowers", async (ctx) => { 4 4 const { ok, params, lookup, blobUrl, packCursor, unpackCursor } = ctx; 5 - const { actor, limit = 50, cursor } = params; 5 + const { actor, viewer, limit = 50, cursor } = params; 6 6 7 7 const offset = cursor ? Number(unpackCursor(cursor)?.primary ?? 0) : 0; 8 8 9 - const rows = (await ctx.db.query( 10 - `SELECT did, cid FROM "social.grain.graph.follow" WHERE subject = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, 11 - [actor, Number(limit) + 1, offset], 12 - )) as { did: string; cid: string }[]; 9 + const [rows, countRows] = await Promise.all([ 10 + ctx.db.query( 11 + `SELECT did, cid FROM "social.grain.graph.follow" WHERE subject = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, 12 + [actor, Number(limit) + 1, offset], 13 + ) as Promise<{ did: string; cid: string }[]>, 14 + ctx.db.query(`SELECT COUNT(*) as count FROM "social.grain.graph.follow" WHERE subject = $1`, [ 15 + actor, 16 + ]) as Promise<{ count: number }[]>, 17 + ]); 18 + const totalCount = Number(countRows[0]?.count ?? 0); 13 19 14 20 const hasMore = rows.length > Number(limit); 15 21 const page = hasMore ? rows.slice(0, Number(limit)) : rows; 16 22 const dids = [...new Set(page.map((r) => r.did))]; 17 23 18 - const profiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids); 24 + const [profiles, viewerFollows] = await Promise.all([ 25 + lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids), 26 + viewer && dids.length > 0 27 + ? (ctx.db.query( 28 + `SELECT subject, uri FROM "social.grain.graph.follow" WHERE did = $1 AND subject IN (${dids.map((_, i) => `$${i + 2}`).join(", ")})`, 29 + [viewer, ...dids], 30 + ) as Promise<{ subject: string; uri: string }[]>) 31 + : Promise.resolve([] as { subject: string; uri: string }[]), 32 + ]); 33 + 34 + const viewerFollowMap = new Map(viewerFollows.map((r) => [r.subject, r.uri])); 19 35 20 36 const items = dids.map((did) => { 21 37 const p = profiles.get(did); ··· 25 41 displayName: p?.value.displayName, 26 42 description: p?.value.description, 27 43 avatar: p ? blobUrl(did, p.value.avatar, "avatar") : undefined, 44 + ...(viewer ? { viewer: { following: viewerFollowMap.get(did) } } : {}), 28 45 }; 29 46 }); 30 47 ··· 32 49 const lastRow = page[page.length - 1]; 33 50 34 51 return ok({ 52 + totalCount, 35 53 items, 36 54 ...(hasMore && lastRow ? { cursor: packCursor(nextOffset, lastRow.cid) } : {}), 37 55 });
+24 -6
server/xrpc/getFollowing.ts
··· 2 2 3 3 export default defineQuery("social.grain.unspecced.getFollowing", async (ctx) => { 4 4 const { ok, params, lookup, blobUrl, packCursor, unpackCursor } = ctx; 5 - const { actor, limit = 50, cursor } = params; 5 + const { actor, viewer, limit = 50, cursor } = params; 6 6 7 7 const offset = cursor ? Number(unpackCursor(cursor)?.primary ?? 0) : 0; 8 8 9 - const rows = (await ctx.db.query( 10 - `SELECT subject, cid FROM "social.grain.graph.follow" WHERE did = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, 11 - [actor, Number(limit) + 1, offset], 12 - )) as { subject: string; cid: string }[]; 9 + const [rows, countRows] = await Promise.all([ 10 + ctx.db.query( 11 + `SELECT subject, cid FROM "social.grain.graph.follow" WHERE did = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, 12 + [actor, Number(limit) + 1, offset], 13 + ) as Promise<{ subject: string; cid: string }[]>, 14 + ctx.db.query(`SELECT COUNT(*) as count FROM "social.grain.graph.follow" WHERE did = $1`, [ 15 + actor, 16 + ]) as Promise<{ count: number }[]>, 17 + ]); 18 + const totalCount = Number(countRows[0]?.count ?? 0); 13 19 14 20 const hasMore = rows.length > Number(limit); 15 21 const page = hasMore ? rows.slice(0, Number(limit)) : rows; 16 22 const dids = [...new Set(page.map((r) => r.subject))]; 17 23 18 - const profiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids); 24 + const [profiles, viewerFollows] = await Promise.all([ 25 + lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids), 26 + viewer && dids.length > 0 27 + ? (ctx.db.query( 28 + `SELECT subject, uri FROM "social.grain.graph.follow" WHERE did = $1 AND subject IN (${dids.map((_, i) => `$${i + 2}`).join(", ")})`, 29 + [viewer, ...dids], 30 + ) as Promise<{ subject: string; uri: string }[]>) 31 + : Promise.resolve([] as { subject: string; uri: string }[]), 32 + ]); 33 + 34 + const viewerFollowMap = new Map(viewerFollows.map((r) => [r.subject, r.uri])); 19 35 20 36 const items = dids.map((did) => { 21 37 const p = profiles.get(did); ··· 25 41 displayName: p?.value.displayName, 26 42 description: p?.value.description, 27 43 avatar: p ? blobUrl(did, p.value.avatar, "avatar") : undefined, 44 + ...(viewer ? { viewer: { following: viewerFollowMap.get(did) } } : {}), 28 45 }; 29 46 }); 30 47 ··· 32 49 const lastRow = page[page.length - 1]; 33 50 34 51 return ok({ 52 + totalCount, 35 53 items, 36 54 ...(hasMore && lastRow ? { cursor: packCursor(nextOffset, lastRow.cid) } : {}), 37 55 });