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

Configure Feed

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

WIP social graph feature

+1625 -21
+13 -1
CLAUDE.md
··· 179 179 - XRPC server implementation 180 180 - Database patterns and migrations 181 181 - Post storage and retrieval 182 - - Account management 182 + - Social graph (follow relationships) 183 + - Profile management (wafrn-specific + caching) 184 + - Account management (operational data) 183 185 - ID resolution 184 186 185 187 - **Client**: `packages/client/CLAUDE.md` ··· 262 264 - ✅ User authentication flow 263 265 - ✅ Basic UI components (Header, PostFeed, UserMenu) 264 266 - ✅ Lexicon type generation 267 + - ✅ Social graph (follow relationships) 268 + - Follow/unfollow operations with transaction-safe count updates 269 + - Follower and following list queries (returns DIDs for batch enrichment) 270 + - Denormalized follow counts for fast profile queries 271 + - Federation-friendly (no foreign key constraints) 272 + - ✅ Profile system (separated architecture) 273 + - Operational account data (roles, status, moderation) 274 + - Wafrn-specific profile customization (HTML bio, custom fields) 275 + - Optional standard profile caching for performance 276 + - Batch profile queries for efficient rendering 265 277 266 278 ### Planned Features 267 279 - **Job Queue & Workers**: Background processing for posts, firehose consumption, timelines
+2 -9
lexicons.json
··· 1 1 { 2 - "lexicons": [ 3 - "com.atproto.label.defs", 4 - "com.atproto.repo.getRecord", 5 - "com.atproto.repo.listRecords", 6 - "com.atproto.repo.createRecord", 7 - "com.atproto.repo.putRecord", 8 - "com.atproto.repo.deleteRecord" 9 - ] 10 - } 2 + "lexicons": ["com.atproto.repo.strongRef"] 3 + }
+54
lexicons/app/wafrn/actor/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.actor.defs", 4 + "defs": { 5 + "profileView": { 6 + "type": "object", 7 + "description": "Wafrn-specific profile information view", 8 + "required": ["did"], 9 + "properties": { 10 + "did": { "type": "string", "format": "did" }, 11 + "htmlBio": { 12 + "type": "string", 13 + "description": "HTML-formatted biography" 14 + }, 15 + "serverOrigin": { 16 + "type": "string", 17 + "description": "User's server origin information" 18 + }, 19 + "customFields": { 20 + "type": "array", 21 + "description": "Custom profile fields", 22 + "items": { 23 + "type": "ref", 24 + "ref": "app.wafrn.actor.profile#customField" 25 + } 26 + }, 27 + "followingCount": { 28 + "type": "integer", 29 + "description": "Number of followers" 30 + }, 31 + "followerCount": { 32 + "type": "integer", 33 + "description": "Number of users following this actor" 34 + } 35 + } 36 + }, 37 + "profileViewDetailed": { 38 + "type": "object", 39 + "description": "Detailed profile view including operational status", 40 + "required": ["did", "handle", "role", "active"], 41 + "properties": { 42 + "did": { "type": "string", "format": "did" }, 43 + "handle": { "type": "string", "format": "handle" }, 44 + "role": { 45 + "type": "string", 46 + "knownValues": ["user", "admin", "moderator"] 47 + }, 48 + "active": { "type": "boolean" }, 49 + "status": { "type": "string" }, 50 + "wafrn": { "type": "ref", "ref": "#profileView" } 51 + } 52 + } 53 + } 54 + }
+41
lexicons/app/wafrn/actor/getProfiles.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.actor.getProfiles", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get wafrn-specific profile information for multiple accounts", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actors"], 11 + "properties": { 12 + "actors": { 13 + "type": "array", 14 + "description": "List of DIDs to fetch profiles for", 15 + "maxLength": 25, 16 + "items": { 17 + "type": "string", 18 + "format": "did" 19 + } 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": ["profiles"], 28 + "properties": { 29 + "profiles": { 30 + "type": "array", 31 + "items": { 32 + "type": "ref", 33 + "ref": "app.wafrn.actor.defs#profileView" 34 + } 35 + } 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }
+51
lexicons/app/wafrn/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Wafrn-specific profile information (sidecar to app.bsky.actor.profile)", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "htmlBio": { 13 + "type": "string", 14 + "description": "HTML-formatted biography", 15 + "maxLength": 10000 16 + }, 17 + "serverOrigin": { 18 + "type": "string", 19 + "description": "User's server origin information", 20 + "maxLength": 256 21 + }, 22 + "customFields": { 23 + "type": "array", 24 + "description": "Custom profile fields", 25 + "maxLength": 10, 26 + "items": { 27 + "type": "ref", 28 + "ref": "#customField" 29 + } 30 + } 31 + } 32 + } 33 + }, 34 + "customField": { 35 + "type": "object", 36 + "required": ["key", "value"], 37 + "properties": { 38 + "key": { 39 + "type": "string", 40 + "description": "Field name/label", 41 + "maxLength": 64 42 + }, 43 + "value": { 44 + "type": "string", 45 + "description": "Field value", 46 + "maxLength": 256 47 + } 48 + } 49 + } 50 + } 51 + }
+50
lexicons/app/wafrn/graph/cacheFollow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.graph.cacheFollow", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Cache a follow record in the AppView", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["follower", "followee", "uri", "createdAt"], 13 + "properties": { 14 + "follower": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the follower" 18 + }, 19 + "followee": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "DID of the account being followed" 23 + }, 24 + "uri": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "URI of the follow record" 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime" 32 + } 33 + } 34 + } 35 + }, 36 + "output": { 37 + "encoding": "application/json", 38 + "schema": { 39 + "type": "object", 40 + "required": ["indexed"], 41 + "properties": { 42 + "indexed": { 43 + "type": "boolean" 44 + } 45 + } 46 + } 47 + } 48 + } 49 + } 50 + }
+41
lexicons/app/wafrn/graph/deleteFollow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.graph.deleteFollow", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a follow record from the AppView cache", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["follower", "followee"], 13 + "properties": { 14 + "follower": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the follower" 18 + }, 19 + "followee": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "DID of the account being followed" 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": ["deleted"], 32 + "properties": { 33 + "deleted": { 34 + "type": "boolean" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }
+26
lexicons/app/wafrn/graph/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.graph.follow", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record representing a 'follow' relationship", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "DID of the account being followed" 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime" 21 + } 22 + } 23 + } 24 + } 25 + } 26 + }
+69
lexicons/app/wafrn/graph/getFollowers.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.graph.getFollowers", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a list of DIDs who follow the specified actor", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "DID of the account" 16 + }, 17 + "limit": { 18 + "type": "integer", 19 + "minimum": 1, 20 + "maximum": 100, 21 + "default": 50 22 + }, 23 + "cursor": { 24 + "type": "string", 25 + "description": "Pagination cursor (timestamp)" 26 + } 27 + } 28 + }, 29 + "output": { 30 + "encoding": "application/json", 31 + "schema": { 32 + "type": "object", 33 + "required": ["followers"], 34 + "properties": { 35 + "cursor": { 36 + "type": "string" 37 + }, 38 + "followers": { 39 + "type": "array", 40 + "items": { 41 + "type": "ref", 42 + "ref": "#follower" 43 + } 44 + } 45 + } 46 + } 47 + } 48 + }, 49 + "follower": { 50 + "type": "object", 51 + "required": ["did", "createdAt"], 52 + "properties": { 53 + "did": { 54 + "type": "string", 55 + "format": "did" 56 + }, 57 + "createdAt": { 58 + "type": "string", 59 + "format": "datetime" 60 + }, 61 + "uri": { 62 + "type": "string", 63 + "format": "at-uri", 64 + "description": "URI of the follow record" 65 + } 66 + } 67 + } 68 + } 69 + }
+69
lexicons/app/wafrn/graph/getFollows.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.graph.getFollows", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a list of DIDs that the specified actor follows", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "DID of the account" 16 + }, 17 + "limit": { 18 + "type": "integer", 19 + "minimum": 1, 20 + "maximum": 100, 21 + "default": 50 22 + }, 23 + "cursor": { 24 + "type": "string", 25 + "description": "Pagination cursor (timestamp)" 26 + } 27 + } 28 + }, 29 + "output": { 30 + "encoding": "application/json", 31 + "schema": { 32 + "type": "object", 33 + "required": ["follows"], 34 + "properties": { 35 + "cursor": { 36 + "type": "string" 37 + }, 38 + "follows": { 39 + "type": "array", 40 + "items": { 41 + "type": "ref", 42 + "ref": "#follow" 43 + } 44 + } 45 + } 46 + } 47 + } 48 + }, 49 + "follow": { 50 + "type": "object", 51 + "required": ["did", "createdAt"], 52 + "properties": { 53 + "did": { 54 + "type": "string", 55 + "format": "did" 56 + }, 57 + "createdAt": { 58 + "type": "string", 59 + "format": "datetime" 60 + }, 61 + "uri": { 62 + "type": "string", 63 + "format": "at-uri", 64 + "description": "URI of the follow record" 65 + } 66 + } 67 + } 68 + } 69 + }
+24
lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "uri", 10 + "cid" 11 + ], 12 + "properties": { 13 + "cid": { 14 + "type": "string", 15 + "format": "cid" 16 + }, 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + } 21 + } 22 + } 23 + } 24 + }
+9
packages/lexicon/index.ts
··· 1 + export * as AppWafrnActorDefs from "./types/app/wafrn/actor/defs.js"; 2 + export * as AppWafrnActorGetProfiles from "./types/app/wafrn/actor/getProfiles.js"; 3 + export * as AppWafrnActorProfile from "./types/app/wafrn/actor/profile.js"; 1 4 export * as AppWafrnContentCachePost from "./types/app/wafrn/content/cachePost.js"; 2 5 export * as AppWafrnContentDefs from "./types/app/wafrn/content/defs.js"; 3 6 export * as AppWafrnContentGetFeed from "./types/app/wafrn/content/getFeed.js"; 4 7 export * as AppWafrnContentPrivatePost from "./types/app/wafrn/content/privatePost.js"; 5 8 export * as AppWafrnContentPublicPost from "./types/app/wafrn/content/publicPost.js"; 9 + export * as AppWafrnGraphCacheFollow from "./types/app/wafrn/graph/cacheFollow.js"; 10 + export * as AppWafrnGraphDeleteFollow from "./types/app/wafrn/graph/deleteFollow.js"; 11 + export * as AppWafrnGraphFollow from "./types/app/wafrn/graph/follow.js"; 12 + export * as AppWafrnGraphGetFollowers from "./types/app/wafrn/graph/getFollowers.js"; 13 + export * as AppWafrnGraphGetFollows from "./types/app/wafrn/graph/getFollows.js"; 14 + export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js";
+30
packages/lexicon/types/app/bsky/graph/follow.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 ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.record( 7 + /*#__PURE__*/ v.tidString(), 8 + /*#__PURE__*/ v.object({ 9 + $type: /*#__PURE__*/ v.literal("app.bsky.graph.follow"), 10 + createdAt: /*#__PURE__*/ v.datetimeString(), 11 + subject: /*#__PURE__*/ v.didString(), 12 + get via() { 13 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 14 + }, 15 + }), 16 + ); 17 + 18 + type main$schematype = typeof _mainSchema; 19 + 20 + export interface mainSchema extends main$schematype {} 21 + 22 + export const mainSchema = _mainSchema as mainSchema; 23 + 24 + export interface Main extends v.InferInput<typeof mainSchema> {} 25 + 26 + declare module "@atcute/lexicons/ambient" { 27 + interface Records { 28 + "app.bsky.graph.follow": mainSchema; 29 + } 30 + }
+64
packages/lexicon/types/app/wafrn/actor/defs.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import * as AppWafrnActorProfile from "./profile.js"; 4 + 5 + const _profileViewSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("app.wafrn.actor.defs#profileView"), 8 + ), 9 + /** 10 + * Custom profile fields 11 + */ 12 + get customFields() { 13 + return /*#__PURE__*/ v.optional( 14 + /*#__PURE__*/ v.array(AppWafrnActorProfile.customFieldSchema), 15 + ); 16 + }, 17 + did: /*#__PURE__*/ v.didString(), 18 + /** 19 + * Number of users following this actor 20 + */ 21 + followerCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 22 + /** 23 + * Number of followers 24 + */ 25 + followingCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 26 + /** 27 + * HTML-formatted biography 28 + */ 29 + htmlBio: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 30 + /** 31 + * User's server origin information 32 + */ 33 + serverOrigin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 34 + }); 35 + const _profileViewDetailedSchema = /*#__PURE__*/ v.object({ 36 + $type: /*#__PURE__*/ v.optional( 37 + /*#__PURE__*/ v.literal("app.wafrn.actor.defs#profileViewDetailed"), 38 + ), 39 + active: /*#__PURE__*/ v.boolean(), 40 + did: /*#__PURE__*/ v.didString(), 41 + handle: /*#__PURE__*/ v.handleString(), 42 + role: /*#__PURE__*/ v.string< 43 + "admin" | "moderator" | "user" | (string & {}) 44 + >(), 45 + status: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 46 + get wafrn() { 47 + return /*#__PURE__*/ v.optional(profileViewSchema); 48 + }, 49 + }); 50 + 51 + type profileView$schematype = typeof _profileViewSchema; 52 + type profileViewDetailed$schematype = typeof _profileViewDetailedSchema; 53 + 54 + export interface profileViewSchema extends profileView$schematype {} 55 + export interface profileViewDetailedSchema 56 + extends profileViewDetailed$schematype {} 57 + 58 + export const profileViewSchema = _profileViewSchema as profileViewSchema; 59 + export const profileViewDetailedSchema = 60 + _profileViewDetailedSchema as profileViewDetailedSchema; 61 + 62 + export interface ProfileView extends v.InferInput<typeof profileViewSchema> {} 63 + export interface ProfileViewDetailed 64 + extends v.InferInput<typeof profileViewDetailedSchema> {}
+41
packages/lexicon/types/app/wafrn/actor/getProfiles.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 AppWafrnActorDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("app.wafrn.actor.getProfiles", { 7 + params: /*#__PURE__*/ v.object({ 8 + /** 9 + * List of DIDs to fetch profiles for 10 + * @minLength 1 11 + * @maxLength 25 12 + */ 13 + actors: /*#__PURE__*/ v.constrain( 14 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.didString()), 15 + [/*#__PURE__*/ v.arrayLength(1, 25)], 16 + ), 17 + }), 18 + output: { 19 + type: "lex", 20 + schema: /*#__PURE__*/ v.object({ 21 + get profiles() { 22 + return /*#__PURE__*/ v.array(AppWafrnActorDefs.profileViewSchema); 23 + }, 24 + }), 25 + }, 26 + }); 27 + 28 + type main$schematype = typeof _mainSchema; 29 + 30 + export interface mainSchema extends main$schematype {} 31 + 32 + export const mainSchema = _mainSchema as mainSchema; 33 + 34 + export interface $params extends v.InferInput<mainSchema["params"]> {} 35 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 36 + 37 + declare module "@atcute/lexicons/ambient" { 38 + interface XRPCQueries { 39 + "app.wafrn.actor.getProfiles": mainSchema; 40 + } 41 + }
+76
packages/lexicon/types/app/wafrn/actor/profile.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _customFieldSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("app.wafrn.actor.profile#customField"), 8 + ), 9 + /** 10 + * Field name/label 11 + * @maxLength 64 12 + */ 13 + key: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 14 + /*#__PURE__*/ v.stringLength(0, 64), 15 + ]), 16 + /** 17 + * Field value 18 + * @maxLength 256 19 + */ 20 + value: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 21 + /*#__PURE__*/ v.stringLength(0, 256), 22 + ]), 23 + }); 24 + const _mainSchema = /*#__PURE__*/ v.record( 25 + /*#__PURE__*/ v.literal("self"), 26 + /*#__PURE__*/ v.object({ 27 + $type: /*#__PURE__*/ v.literal("app.wafrn.actor.profile"), 28 + /** 29 + * Custom profile fields 30 + * @maxLength 10 31 + */ 32 + get customFields() { 33 + return /*#__PURE__*/ v.optional( 34 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(customFieldSchema), [ 35 + /*#__PURE__*/ v.arrayLength(0, 10), 36 + ]), 37 + ); 38 + }, 39 + /** 40 + * HTML-formatted biography 41 + * @maxLength 10000 42 + */ 43 + htmlBio: /*#__PURE__*/ v.optional( 44 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 45 + /*#__PURE__*/ v.stringLength(0, 10000), 46 + ]), 47 + ), 48 + /** 49 + * User's server origin information 50 + * @maxLength 256 51 + */ 52 + serverOrigin: /*#__PURE__*/ v.optional( 53 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 54 + /*#__PURE__*/ v.stringLength(0, 256), 55 + ]), 56 + ), 57 + }), 58 + ); 59 + 60 + type customField$schematype = typeof _customFieldSchema; 61 + type main$schematype = typeof _mainSchema; 62 + 63 + export interface customFieldSchema extends customField$schematype {} 64 + export interface mainSchema extends main$schematype {} 65 + 66 + export const customFieldSchema = _customFieldSchema as customFieldSchema; 67 + export const mainSchema = _mainSchema as mainSchema; 68 + 69 + export interface CustomField extends v.InferInput<typeof customFieldSchema> {} 70 + export interface Main extends v.InferInput<typeof mainSchema> {} 71 + 72 + declare module "@atcute/lexicons/ambient" { 73 + interface Records { 74 + "app.wafrn.actor.profile": mainSchema; 75 + } 76 + }
+47
packages/lexicon/types/app/wafrn/graph/cacheFollow.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("app.wafrn.graph.cacheFollow", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + createdAt: /*#__PURE__*/ v.datetimeString(), 11 + /** 12 + * DID of the account being followed 13 + */ 14 + followee: /*#__PURE__*/ v.didString(), 15 + /** 16 + * DID of the follower 17 + */ 18 + follower: /*#__PURE__*/ v.didString(), 19 + /** 20 + * URI of the follow record 21 + */ 22 + uri: /*#__PURE__*/ v.resourceUriString(), 23 + }), 24 + }, 25 + output: { 26 + type: "lex", 27 + schema: /*#__PURE__*/ v.object({ 28 + indexed: /*#__PURE__*/ v.boolean(), 29 + }), 30 + }, 31 + }); 32 + 33 + type main$schematype = typeof _mainSchema; 34 + 35 + export interface mainSchema extends main$schematype {} 36 + 37 + export const mainSchema = _mainSchema as mainSchema; 38 + 39 + export interface $params {} 40 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 41 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 42 + 43 + declare module "@atcute/lexicons/ambient" { 44 + interface XRPCProcedures { 45 + "app.wafrn.graph.cacheFollow": mainSchema; 46 + } 47 + }
+42
packages/lexicon/types/app/wafrn/graph/deleteFollow.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("app.wafrn.graph.deleteFollow", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + /** 11 + * DID of the account being followed 12 + */ 13 + followee: /*#__PURE__*/ v.didString(), 14 + /** 15 + * DID of the follower 16 + */ 17 + follower: /*#__PURE__*/ v.didString(), 18 + }), 19 + }, 20 + output: { 21 + type: "lex", 22 + schema: /*#__PURE__*/ v.object({ 23 + deleted: /*#__PURE__*/ v.boolean(), 24 + }), 25 + }, 26 + }); 27 + 28 + type main$schematype = typeof _mainSchema; 29 + 30 + export interface mainSchema extends main$schematype {} 31 + 32 + export const mainSchema = _mainSchema as mainSchema; 33 + 34 + export interface $params {} 35 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 36 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 37 + 38 + declare module "@atcute/lexicons/ambient" { 39 + interface XRPCProcedures { 40 + "app.wafrn.graph.deleteFollow": mainSchema; 41 + } 42 + }
+29
packages/lexicon/types/app/wafrn/graph/follow.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.tidString(), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("app.wafrn.graph.follow"), 9 + createdAt: /*#__PURE__*/ v.datetimeString(), 10 + /** 11 + * DID of the account being followed 12 + */ 13 + subject: /*#__PURE__*/ v.didString(), 14 + }), 15 + ); 16 + 17 + type main$schematype = typeof _mainSchema; 18 + 19 + export interface mainSchema extends main$schematype {} 20 + 21 + export const mainSchema = _mainSchema as mainSchema; 22 + 23 + export interface Main extends v.InferInput<typeof mainSchema> {} 24 + 25 + declare module "@atcute/lexicons/ambient" { 26 + interface Records { 27 + "app.wafrn.graph.follow": mainSchema; 28 + } 29 + }
+67
packages/lexicon/types/app/wafrn/graph/getFollowers.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _followerSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("app.wafrn.graph.getFollowers#follower"), 8 + ), 9 + createdAt: /*#__PURE__*/ v.datetimeString(), 10 + did: /*#__PURE__*/ v.didString(), 11 + /** 12 + * URI of the follow record 13 + */ 14 + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 15 + }); 16 + const _mainSchema = /*#__PURE__*/ v.query("app.wafrn.graph.getFollowers", { 17 + params: /*#__PURE__*/ v.object({ 18 + /** 19 + * DID of the account 20 + */ 21 + actor: /*#__PURE__*/ v.didString(), 22 + /** 23 + * Pagination cursor (timestamp) 24 + */ 25 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 26 + /** 27 + * @minimum 1 28 + * @maximum 100 29 + * @default 50 30 + */ 31 + limit: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 33 + /*#__PURE__*/ v.integerRange(1, 100), 34 + ]), 35 + 50, 36 + ), 37 + }), 38 + output: { 39 + type: "lex", 40 + schema: /*#__PURE__*/ v.object({ 41 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 42 + get followers() { 43 + return /*#__PURE__*/ v.array(followerSchema); 44 + }, 45 + }), 46 + }, 47 + }); 48 + 49 + type follower$schematype = typeof _followerSchema; 50 + type main$schematype = typeof _mainSchema; 51 + 52 + export interface followerSchema extends follower$schematype {} 53 + export interface mainSchema extends main$schematype {} 54 + 55 + export const followerSchema = _followerSchema as followerSchema; 56 + export const mainSchema = _mainSchema as mainSchema; 57 + 58 + export interface Follower extends v.InferInput<typeof followerSchema> {} 59 + 60 + export interface $params extends v.InferInput<mainSchema["params"]> {} 61 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 62 + 63 + declare module "@atcute/lexicons/ambient" { 64 + interface XRPCQueries { 65 + "app.wafrn.graph.getFollowers": mainSchema; 66 + } 67 + }
+67
packages/lexicon/types/app/wafrn/graph/getFollows.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _followSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("app.wafrn.graph.getFollows#follow"), 8 + ), 9 + createdAt: /*#__PURE__*/ v.datetimeString(), 10 + did: /*#__PURE__*/ v.didString(), 11 + /** 12 + * URI of the follow record 13 + */ 14 + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 15 + }); 16 + const _mainSchema = /*#__PURE__*/ v.query("app.wafrn.graph.getFollows", { 17 + params: /*#__PURE__*/ v.object({ 18 + /** 19 + * DID of the account 20 + */ 21 + actor: /*#__PURE__*/ v.didString(), 22 + /** 23 + * Pagination cursor (timestamp) 24 + */ 25 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 26 + /** 27 + * @minimum 1 28 + * @maximum 100 29 + * @default 50 30 + */ 31 + limit: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 33 + /*#__PURE__*/ v.integerRange(1, 100), 34 + ]), 35 + 50, 36 + ), 37 + }), 38 + output: { 39 + type: "lex", 40 + schema: /*#__PURE__*/ v.object({ 41 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 42 + get follows() { 43 + return /*#__PURE__*/ v.array(followSchema); 44 + }, 45 + }), 46 + }, 47 + }); 48 + 49 + type follow$schematype = typeof _followSchema; 50 + type main$schematype = typeof _mainSchema; 51 + 52 + export interface followSchema extends follow$schematype {} 53 + export interface mainSchema extends main$schematype {} 54 + 55 + export const followSchema = _followSchema as followSchema; 56 + export const mainSchema = _mainSchema as mainSchema; 57 + 58 + export interface Follow extends v.InferInput<typeof followSchema> {} 59 + 60 + export interface $params extends v.InferInput<mainSchema["params"]> {} 61 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 62 + 63 + declare module "@atcute/lexicons/ambient" { 64 + interface XRPCQueries { 65 + "app.wafrn.graph.getFollows": mainSchema; 66 + } 67 + }
+18
packages/lexicon/types/com/atproto/repo/strongRef.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + 4 + const _mainSchema = /*#__PURE__*/ v.object({ 5 + $type: /*#__PURE__*/ v.optional( 6 + /*#__PURE__*/ v.literal("com.atproto.repo.strongRef"), 7 + ), 8 + cid: /*#__PURE__*/ v.cidString(), 9 + uri: /*#__PURE__*/ v.resourceUriString(), 10 + }); 11 + 12 + type main$schematype = typeof _mainSchema; 13 + 14 + export interface mainSchema extends main$schematype {} 15 + 16 + export const mainSchema = _mainSchema as mainSchema; 17 + 18 + export interface Main extends v.InferInput<typeof mainSchema> {}
+55 -9
packages/server/CLAUDE.md
··· 69 69 │ ├── lib/ 70 70 │ │ ├── env.ts # Environment variable validation 71 71 │ │ ├── account.ts # Account management logic 72 + │ │ ├── follow.ts # Follow relationship management 73 + │ │ ├── profile.ts # Profile management (wafrn-specific + cache) 72 74 │ │ ├── xrpcServer.ts # XRPC server instance and configuration 73 75 │ │ └── idResolver.ts # ATProto identity resolution 74 76 │ └── db/ ··· 78 80 │ ├── 1761580548730_accounts.ts 79 81 │ ├── 1762114354466_public_posts.ts 80 82 │ ├── 1762179949540_private_posts.ts 81 - │ └── 1762179953814_tags.ts 83 + │ ├── 1762179953814_tags.ts 84 + │ └── 1762527898283_social_graph.ts 82 85 ├── data/ # SQLite database files 83 86 ├── package.json # Package dependencies and scripts 84 87 ├── tsconfig.json # TypeScript configuration ··· 105 108 - Retrieve posts by account or tag 106 109 - Tag management for posts 107 110 108 - 2. **Account Management**: 109 - - Upsert account records (DID, handle, metadata) 111 + 2. **Social Graph**: 112 + - Index follow relationships from ATProto network 113 + - Query followers and following lists 114 + - Maintain denormalized follow counts 115 + - Cache follow data for efficient querying 116 + 117 + 3. **Profile Management**: 118 + - Store wafrn-specific profile metadata (HTML bio, custom fields) 119 + - Optionally cache standard profile data (avatars, display names) 120 + - Batch profile queries for efficient client rendering 121 + 122 + 4. **Account Management**: 123 + - Upsert account records (DID, handle, operational metadata) 110 124 - Query account information 111 125 - Account existence validation 126 + - Operational status tracking (active, roles, moderation status) 112 127 113 - 3. **Identity Resolution**: 128 + 5. **Identity Resolution**: 114 129 - Resolve ATProto DIDs to handles 115 130 - Resolve handles to DIDs 116 131 - PDS discovery for ATProto accounts ··· 120 135 121 136 ### Database Architecture 122 137 123 - **Tables:** 124 - - `accounts` - User account records with DID (primary key), handle, PDS URL, metadata 125 - - `public_posts` - Public posts with TID, account DID, text content, facets, created timestamp 126 - - `private_posts` - Private posts with same structure as public posts 127 - - `tags` - Tag associations: tag name, post type (public/private), post TID 138 + **Core Tables:** 139 + 140 + - **`accounts`** - Operational account data only (separated from profile data) 141 + - Primary key: `did` (ATProto DID) 142 + - Fields: `handle`, `role`, `active`, `status`, timestamps 143 + - Purpose: Access control, moderation, operational tracking 144 + - No foreign keys to preserve federation independence 145 + 146 + - **`profiles`** - Wafrn-specific and cached profile data 147 + - Primary key: `did` 148 + - Wafrn fields: `html_bio`, `server_origin`, `custom_fields` (JSON) 149 + - Cached fields: `display_name`, `avatar_url`, `description`, `banner_url` 150 + - Foreign key to `accounts` (CASCADE on delete) 151 + - Purpose: App-specific customization + optional standard profile cache 152 + 153 + **Post Tables:** 154 + - **`public_posts`** - Public posts with HTML/Markdown content 155 + - **`private_posts`** - Encrypted private posts 156 + - **`tags`** - Tag definitions 157 + - **`public_post_tags`** - Junction table for post-tag associations 158 + 159 + **Social Graph Tables:** 160 + 161 + - **`follows`** - Follow relationships indexed from ATProto network 162 + - Composite primary key: (`follower_did`, `followee_did`) 163 + - Fields: `uri` (ATProto record URI), `created_at`, `indexed_at` 164 + - No foreign keys to accounts (federated - any DID can follow any DID) 165 + - Indexes: `follower_did`, `followee_did`, `uri`, `created_at` 166 + 167 + - **`follow_counts`** - Denormalized follower/following counts 168 + - Primary key: `did` 169 + - Fields: `follower_count`, `following_count`, `updated_at` 170 + - Updated transactionally with follow operations 171 + - Index on `follower_count` for popularity queries 128 172 129 173 **Key Patterns:** 130 174 - Migrations run automatically on server start via `migrateDBToLatest()` ··· 132 176 - Schema types auto-generated via `kysely-codegen` - regenerate after schema changes 133 177 - WAL mode enabled for better concurrent access 134 178 - TIDs (Timestamp IDs) used for post identifiers (ATProto standard) 179 + - **Separated concerns**: Operational data (accounts) vs profile data (profiles) vs relationships (follows) 180 + - **Federation-friendly**: No foreign key constraints on follows table 135 181 136 182 ### Environment Variables 137 183
+115
packages/server/src/db/migrations/1762527898283_social_graph.ts
··· 1 + import { sql, type Kysely } from 'kysely' 2 + 3 + // Migration for social graph: follows, follow_counts, and profiles tables 4 + export async function up(db: Kysely<any>): Promise<void> { 5 + // ==================================== 6 + // 1. CREATE FOLLOWS TABLE 7 + // ==================================== 8 + await db.schema 9 + .createTable('follows') 10 + .addColumn('follower_did', 'text', (col) => col.notNull()) 11 + .addColumn('followee_did', 'text', (col) => col.notNull()) 12 + .addColumn('uri', 'text', (col) => col.notNull()) 13 + .addColumn('created_at', 'integer', (col) => col.notNull()) 14 + .addColumn('indexed_at', 'integer', (col) => 15 + col.notNull().defaultTo(sql`(unixepoch() * 1000)`) 16 + ) 17 + .addPrimaryKeyConstraint('follows_pk', ['follower_did', 'followee_did']) 18 + .execute() 19 + 20 + // Create indexes for follows table 21 + await db.schema 22 + .createIndex('follows_follower_did_idx') 23 + .on('follows') 24 + .column('follower_did') 25 + .execute() 26 + 27 + await db.schema 28 + .createIndex('follows_followee_did_idx') 29 + .on('follows') 30 + .column('followee_did') 31 + .execute() 32 + 33 + await db.schema 34 + .createIndex('follows_uri_idx') 35 + .on('follows') 36 + .column('uri') 37 + .execute() 38 + 39 + await db.schema 40 + .createIndex('follows_created_at_idx') 41 + .on('follows') 42 + .columns(['created_at']) 43 + .execute() 44 + 45 + // ==================================== 46 + // 2. CREATE FOLLOW_COUNTS TABLE 47 + // ==================================== 48 + await db.schema 49 + .createTable('follow_counts') 50 + .addColumn('did', 'text', (col) => col.primaryKey().notNull()) 51 + .addColumn('follower_count', 'integer', (col) => col.notNull().defaultTo(0)) 52 + .addColumn('following_count', 'integer', (col) => col.notNull().defaultTo(0)) 53 + .addColumn('updated_at', 'integer', (col) => 54 + col.notNull().defaultTo(sql`(unixepoch() * 1000)`) 55 + ) 56 + .execute() 57 + 58 + // Create index for sorting by follower count (popularity) 59 + await db.schema 60 + .createIndex('follow_counts_follower_count_idx') 61 + .on('follow_counts') 62 + .columns(['follower_count']) 63 + .execute() 64 + 65 + // ==================================== 66 + // 3. CREATE PROFILES TABLE 67 + // ==================================== 68 + await db.schema 69 + .createTable('profiles') 70 + .addColumn('did', 'text', (col) => col.primaryKey().notNull()) 71 + // Wafrn-specific profile fields 72 + .addColumn('html_bio', 'text') 73 + .addColumn('server_origin', 'text') 74 + .addColumn('custom_fields', 'text') // JSON string 75 + // Cached standard profile data (optional) 76 + .addColumn('display_name', 'text') 77 + .addColumn('avatar_url', 'text') 78 + .addColumn('description', 'text') 79 + .addColumn('banner_url', 'text') 80 + // Timestamps 81 + .addColumn('profile_cached_at', 'integer') 82 + .addColumn('wafrn_updated_at', 'integer', (col) => 83 + col.notNull().defaultTo(sql`(unixepoch() * 1000)`) 84 + ) 85 + .addForeignKeyConstraint( 86 + 'profiles_did_fk', 87 + ['did'], 88 + 'accounts', 89 + ['did'], 90 + (cb) => cb.onDelete('cascade') 91 + ) 92 + .execute() 93 + 94 + // Create index for cache freshness queries 95 + await db.schema 96 + .createIndex('profiles_cached_at_idx') 97 + .on('profiles') 98 + .column('profile_cached_at') 99 + .execute() 100 + } 101 + 102 + export async function down(db: Kysely<any>): Promise<void> { 103 + // Drop indexes first 104 + await db.schema.dropIndex('follows_follower_did_idx').execute() 105 + await db.schema.dropIndex('follows_followee_did_idx').execute() 106 + await db.schema.dropIndex('follows_uri_idx').execute() 107 + await db.schema.dropIndex('follows_created_at_idx').execute() 108 + await db.schema.dropIndex('follow_counts_follower_count_idx').execute() 109 + await db.schema.dropIndex('profiles_cached_at_idx').execute() 110 + 111 + // Drop tables (order matters due to foreign keys) 112 + await db.schema.dropTable('profiles').execute() 113 + await db.schema.dropTable('follow_counts').execute() 114 + await db.schema.dropTable('follows').execute() 115 + }
+31
packages/server/src/db/schema.d.ts
··· 19 19 status: string | null; 20 20 } 21 21 22 + export interface FollowCounts { 23 + did: string; 24 + follower_count: Generated<number>; 25 + following_count: Generated<number>; 26 + updated_at: Generated<number>; 27 + } 28 + 29 + export interface Follows { 30 + created_at: number; 31 + followee_did: string; 32 + follower_did: string; 33 + indexed_at: Generated<number>; 34 + uri: string; 35 + } 36 + 22 37 export interface PrivatePosts { 23 38 author_did: string; 24 39 created_at: Generated<number>; ··· 30 45 visibility: Generated<string>; 31 46 } 32 47 48 + export interface Profiles { 49 + avatar_url: string | null; 50 + banner_url: string | null; 51 + custom_fields: string | null; 52 + description: string | null; 53 + did: string; 54 + display_name: string | null; 55 + html_bio: string | null; 56 + profile_cached_at: number | null; 57 + server_origin: string | null; 58 + wafrn_updated_at: Generated<number>; 59 + } 60 + 33 61 export interface PublicPosts { 34 62 author_did: string; 35 63 content_html: string; ··· 54 82 55 83 export interface DB { 56 84 accounts: Accounts; 85 + follow_counts: FollowCounts; 86 + follows: Follows; 57 87 private_posts: PrivatePosts; 88 + profiles: Profiles; 58 89 public_post_tags: PublicPostTags; 59 90 public_posts: PublicPosts; 60 91 tags: Tags;
+183
packages/server/src/lib/follow.ts
··· 1 + import { sql } from 'kysely' 2 + import { db } from '@api/db/db' 3 + 4 + /** 5 + * Create a follow relationship between two accounts. 6 + * Updates follow_counts for both accounts in a transaction. 7 + */ 8 + export async function createFollow( 9 + followerDid: string, 10 + followeeDid: string, 11 + uri: string, 12 + createdAt: number 13 + ): Promise<void> { 14 + await db.transaction().execute(async (trx) => { 15 + // Step 1: Insert follow record (idempotent with onConflict) 16 + await trx 17 + .insertInto('follows') 18 + .values({ 19 + follower_did: followerDid, 20 + followee_did: followeeDid, 21 + uri, 22 + created_at: createdAt 23 + }) 24 + .onConflict((oc) => 25 + oc.columns(['follower_did', 'followee_did']).doNothing() 26 + ) 27 + .execute() 28 + 29 + // Step 2: Update following_count for follower 30 + await trx 31 + .insertInto('follow_counts') 32 + .values({ did: followerDid, following_count: 1, follower_count: 0 }) 33 + .onConflict((oc) => 34 + oc.column('did').doUpdateSet({ 35 + following_count: sql`following_count + 1`, 36 + updated_at: sql`(unixepoch() * 1000)` 37 + }) 38 + ) 39 + .execute() 40 + 41 + // Step 3: Update follower_count for followee 42 + await trx 43 + .insertInto('follow_counts') 44 + .values({ did: followeeDid, follower_count: 1, following_count: 0 }) 45 + .onConflict((oc) => 46 + oc.column('did').doUpdateSet({ 47 + follower_count: sql`follower_count + 1`, 48 + updated_at: sql`(unixepoch() * 1000)` 49 + }) 50 + ) 51 + .execute() 52 + }) 53 + } 54 + 55 + /** 56 + * Delete a follow relationship between two accounts. 57 + * Decrements follow_counts for both accounts in a transaction. 58 + */ 59 + export async function deleteFollow( 60 + followerDid: string, 61 + followeeDid: string 62 + ): Promise<void> { 63 + await db.transaction().execute(async (trx) => { 64 + // Step 1: Delete follow record 65 + const result = await trx 66 + .deleteFrom('follows') 67 + .where('follower_did', '=', followerDid) 68 + .where('followee_did', '=', followeeDid) 69 + .execute() 70 + 71 + // Only update counts if a row was actually deleted 72 + if (result[0].numDeletedRows > 0n) { 73 + // Step 2: Decrement following_count for follower (don't go below 0) 74 + await trx 75 + .updateTable('follow_counts') 76 + .set({ 77 + following_count: sql`MAX(following_count - 1, 0)`, 78 + updated_at: sql`(unixepoch() * 1000)` 79 + }) 80 + .where('did', '=', followerDid) 81 + .execute() 82 + 83 + // Step 3: Decrement follower_count for followee (don't go below 0) 84 + await trx 85 + .updateTable('follow_counts') 86 + .set({ 87 + follower_count: sql`MAX(follower_count - 1, 0)`, 88 + updated_at: sql`(unixepoch() * 1000)` 89 + }) 90 + .where('did', '=', followeeDid) 91 + .execute() 92 + } 93 + }) 94 + } 95 + 96 + /** 97 + * Check if followerDid follows followeeDid. 98 + */ 99 + export async function isFollowing( 100 + followerDid: string, 101 + followeeDid: string 102 + ): Promise<boolean> { 103 + const result = await db 104 + .selectFrom('follows') 105 + .select('follower_did') 106 + .where('follower_did', '=', followerDid) 107 + .where('followee_did', '=', followeeDid) 108 + .executeTakeFirst() 109 + 110 + return result !== undefined 111 + } 112 + 113 + /** 114 + * Get the list of DIDs that follow the specified account. 115 + * Returns DIDs only - profile enrichment should be done client-side via batch fetching. 116 + */ 117 + export async function getFollowers( 118 + did: string, 119 + limit: number = 50, 120 + offset: number = 0 121 + ) { 122 + return await db 123 + .selectFrom('follows') 124 + .select(['follower_did as did', 'created_at', 'uri']) 125 + .where('followee_did', '=', did) 126 + .orderBy('created_at', 'desc') 127 + .limit(limit) 128 + .offset(offset) 129 + .execute() 130 + } 131 + 132 + /** 133 + * Get the list of DIDs that the specified account follows. 134 + * Returns DIDs only - profile enrichment should be done client-side via batch fetching. 135 + */ 136 + export async function getFollowing( 137 + did: string, 138 + limit: number = 50, 139 + offset: number = 0 140 + ) { 141 + return await db 142 + .selectFrom('follows') 143 + .select(['followee_did as did', 'created_at', 'uri']) 144 + .where('follower_did', '=', did) 145 + .orderBy('created_at', 'desc') 146 + .limit(limit) 147 + .offset(offset) 148 + .execute() 149 + } 150 + 151 + /** 152 + * Get follower and following counts for an account. 153 + */ 154 + export async function getFollowCounts(did: string) { 155 + const counts = await db 156 + .selectFrom('follow_counts') 157 + .select(['follower_count', 'following_count']) 158 + .where('did', '=', did) 159 + .executeTakeFirst() 160 + 161 + return { 162 + followers: counts?.follower_count ?? 0, 163 + following: counts?.following_count ?? 0 164 + } 165 + } 166 + 167 + /** 168 + * Get multiple accounts' follow counts in batch. 169 + */ 170 + export async function getFollowCountsBatch(dids: string[]) { 171 + if (dids.length === 0) { 172 + return [] 173 + } 174 + 175 + const counts = await db 176 + .selectFrom('follow_counts') 177 + .select(['did', 'follower_count', 'following_count']) 178 + .where('did', 'in', dids) 179 + .execute() 180 + 181 + // Return map for easy lookup 182 + return counts 183 + }
+183
packages/server/src/lib/profile.ts
··· 1 + import { db } from '@api/db/db' 2 + import type { Insertable } from 'kysely' 3 + import type { Accounts, Profiles } from '@api/db/schema' 4 + 5 + /** 6 + * Ensure an account exists in the database. 7 + * Creates the account if it doesn't exist, updates last_login_at if it does. 8 + * This should be called on first interaction with any DID. 9 + */ 10 + export async function ensureAccount(did: string, handle: string): Promise<void> { 11 + await db 12 + .insertInto('accounts') 13 + .values({ 14 + did, 15 + handle, 16 + active: 1, // SQLite stores booleans as 0/1 17 + status: null 18 + }) 19 + .onConflict((oc) => 20 + oc.column('did').doUpdateSet({ 21 + last_login_at: Date.now(), 22 + handle // Update handle in case it changed 23 + }) 24 + ) 25 + .execute() 26 + } 27 + 28 + /** 29 + * Set or update wafrn-specific profile fields for an account. 30 + * Creates profile if it doesn't exist, updates if it does. 31 + */ 32 + export async function setWafrnProfile( 33 + did: string, 34 + profile: { 35 + html_bio?: string 36 + server_origin?: string 37 + custom_fields?: Record<string, string> 38 + } 39 + ): Promise<void> { 40 + await db 41 + .insertInto('profiles') 42 + .values({ 43 + did, 44 + html_bio: profile.html_bio ?? null, 45 + server_origin: profile.server_origin ?? null, 46 + custom_fields: profile.custom_fields 47 + ? JSON.stringify(profile.custom_fields) 48 + : null, 49 + wafrn_updated_at: Date.now() 50 + }) 51 + .onConflict((oc) => 52 + oc.column('did').doUpdateSet({ 53 + html_bio: profile.html_bio ?? null, 54 + server_origin: profile.server_origin ?? null, 55 + custom_fields: profile.custom_fields 56 + ? JSON.stringify(profile.custom_fields) 57 + : null, 58 + wafrn_updated_at: Date.now() 59 + }) 60 + ) 61 + .execute() 62 + } 63 + 64 + /** 65 + * Get wafrn-specific profiles for multiple DIDs in batch. 66 + * Does not include cached standard profile data. 67 + */ 68 + export async function getWafrnProfiles(dids: string[]) { 69 + if (dids.length === 0) { 70 + return [] 71 + } 72 + 73 + const profiles = await db 74 + .selectFrom('profiles') 75 + .select(['did', 'html_bio', 'server_origin', 'custom_fields']) 76 + .where('did', 'in', dids) 77 + .execute() 78 + 79 + return profiles.map((p) => ({ 80 + did: p.did, 81 + htmlBio: p.html_bio, 82 + serverOrigin: p.server_origin, 83 + customFields: p.custom_fields ? JSON.parse(p.custom_fields) : {} 84 + })) 85 + } 86 + 87 + /** 88 + * Cache standard profile data (from app.bsky.actor.profile) for faster queries. 89 + * This is optional - used for performance optimization. 90 + */ 91 + export async function cacheStandardProfile( 92 + did: string, 93 + profile: { 94 + display_name?: string 95 + avatar_url?: string 96 + description?: string 97 + banner_url?: string 98 + } 99 + ): Promise<void> { 100 + await db 101 + .insertInto('profiles') 102 + .values({ 103 + did, 104 + display_name: profile.display_name ?? null, 105 + avatar_url: profile.avatar_url ?? null, 106 + description: profile.description ?? null, 107 + banner_url: profile.banner_url ?? null, 108 + profile_cached_at: Date.now() 109 + }) 110 + .onConflict((oc) => 111 + oc.column('did').doUpdateSet({ 112 + display_name: profile.display_name ?? null, 113 + avatar_url: profile.avatar_url ?? null, 114 + description: profile.description ?? null, 115 + banner_url: profile.banner_url ?? null, 116 + profile_cached_at: Date.now() 117 + }) 118 + ) 119 + .execute() 120 + } 121 + 122 + /** 123 + * Get profiles enriched with account operational status. 124 + * Useful for moderation and admin interfaces. 125 + */ 126 + export async function getProfilesEnriched(dids: string[]) { 127 + if (dids.length === 0) { 128 + return [] 129 + } 130 + 131 + const profiles = await db 132 + .selectFrom('accounts') 133 + .leftJoin('profiles', 'accounts.did', 'profiles.did') 134 + .select([ 135 + 'accounts.did', 136 + 'accounts.handle', 137 + 'accounts.role', 138 + 'accounts.active', 139 + 'accounts.status', 140 + 'profiles.html_bio', 141 + 'profiles.server_origin', 142 + 'profiles.custom_fields', 143 + 'profiles.display_name', 144 + 'profiles.avatar_url', 145 + 'profiles.description', 146 + 'profiles.profile_cached_at' 147 + ]) 148 + .where('accounts.did', 'in', dids) 149 + .execute() 150 + 151 + return profiles.map((p) => ({ 152 + did: p.did, 153 + handle: p.handle, 154 + role: p.role, 155 + active: p.active === 1, // SQLite stores booleans as 0/1 156 + status: p.status, 157 + wafrn: { 158 + htmlBio: p.html_bio, 159 + serverOrigin: p.server_origin, 160 + customFields: p.custom_fields ? JSON.parse(p.custom_fields) : {} 161 + }, 162 + cached: p.profile_cached_at 163 + ? { 164 + displayName: p.display_name, 165 + avatarUrl: p.avatar_url, 166 + description: p.description, 167 + cachedAt: p.profile_cached_at 168 + } 169 + : null 170 + })) 171 + } 172 + 173 + /** 174 + * Check if a profile cache is fresh (within TTL). 175 + * Default TTL: 24 hours 176 + */ 177 + export function isCacheFresh( 178 + cachedAt: number | null, 179 + ttlMs: number = 24 * 60 * 60 * 1000 180 + ): boolean { 181 + if (!cachedAt) return false 182 + return Date.now() - cachedAt < ttlMs 183 + }
+128 -2
packages/server/src/lib/xrpcServer.ts
··· 4 4 import { 5 5 AppWafrnContentCachePost, 6 6 AppWafrnContentDefs, 7 - AppWafrnContentGetFeed 7 + AppWafrnContentGetFeed, 8 + AppWafrnGraphCacheFollow, 9 + AppWafrnGraphDeleteFollow, 10 + AppWafrnGraphGetFollowers, 11 + AppWafrnGraphGetFollows, 12 + AppWafrnActorGetProfiles 8 13 } from '@watproto/lexicon' 9 14 import { didDocResolver } from './idResolver' 10 15 import env from './env' 11 16 import { db } from '@api/db/db' 12 - import { is, type ResourceUri } from '@atcute/lexicons' 17 + import { is, type ResourceUri, type Did } from '@atcute/lexicons' 18 + import { 19 + createFollow, 20 + deleteFollow, 21 + getFollowCountsBatch, 22 + getFollowers, 23 + getFollowing 24 + } from '@api/lib/follow' 25 + import { getWafrnProfiles } from '@api/lib/profile' 13 26 14 27 const jwtVerifier = new ServiceJwtVerifier({ 15 28 resolver: didDocResolver, ··· 225 238 226 239 return json({ 227 240 feed: posts 241 + }) 242 + } 243 + }) 244 + 245 + // ==================================== 246 + // FOLLOW ENDPOINTS 247 + // ==================================== 248 + 249 + xrpcServer.add(AppWafrnGraphCacheFollow.mainSchema, { 250 + async handler({ input, request }) { 251 + const { did } = await authenticateJwt(request) 252 + 253 + // Verify the follower matches the authenticated user 254 + if (did !== input.follower) { 255 + throw new XRPCError({ 256 + status: 403, 257 + error: 'Forbidden', 258 + description: 'You can only cache follows for your own account' 259 + }) 260 + } 261 + 262 + await createFollow( 263 + input.follower, 264 + input.followee, 265 + input.uri, 266 + new Date(input.createdAt).getTime() 267 + ) 268 + 269 + return json({ indexed: true }) 270 + } 271 + }) 272 + 273 + xrpcServer.add(AppWafrnGraphDeleteFollow.mainSchema, { 274 + async handler({ input, request }) { 275 + const { did } = await authenticateJwt(request) 276 + 277 + // Verify the follower matches the authenticated user 278 + if (did !== input.follower) { 279 + throw new XRPCError({ 280 + status: 403, 281 + error: 'Forbidden', 282 + description: 'You can only delete follows for your own account' 283 + }) 284 + } 285 + 286 + await deleteFollow(input.follower, input.followee) 287 + 288 + return json({ deleted: true }) 289 + } 290 + }) 291 + 292 + xrpcServer.addQuery(AppWafrnGraphGetFollowers.mainSchema, { 293 + async handler({ params }) { 294 + const limit = params.limit ?? 50 295 + const offset = params.cursor ? parseInt(params.cursor, 10) : 0 296 + 297 + const followers = await getFollowers(params.actor, limit, offset) 298 + 299 + return json({ 300 + cursor: followers.length === limit ? String(offset + limit) : undefined, 301 + followers: followers.map((f) => ({ 302 + did: f.did as Did, 303 + createdAt: new Date(f.created_at).toISOString(), 304 + uri: f.uri as ResourceUri 305 + })) 306 + }) 307 + } 308 + }) 309 + 310 + xrpcServer.addQuery(AppWafrnGraphGetFollows.mainSchema, { 311 + async handler({ params }) { 312 + const limit = params.limit ?? 50 313 + const offset = params.cursor ? parseInt(params.cursor, 10) : 0 314 + 315 + const follows = await getFollowing(params.actor, limit, offset) 316 + 317 + return json({ 318 + cursor: follows.length === limit ? String(offset + limit) : undefined, 319 + follows: follows.map((f) => ({ 320 + did: f.did as Did, 321 + createdAt: new Date(f.created_at).toISOString(), 322 + uri: f.uri as ResourceUri 323 + })) 324 + }) 325 + } 326 + }) 327 + 328 + // ==================================== 329 + // PROFILE ENDPOINTS 330 + // ==================================== 331 + 332 + xrpcServer.addQuery(AppWafrnActorGetProfiles.mainSchema, { 333 + async handler({ params }) { 334 + const [profiles, counts] = await Promise.all([ 335 + getWafrnProfiles(params.actors), 336 + getFollowCountsBatch(params.actors) 337 + ]) 338 + const countMap = Object.fromEntries( 339 + counts.map((count) => [count.did, count]) 340 + ) 341 + 342 + return json({ 343 + profiles: profiles.map((p) => ({ 344 + did: p.did as Did, 345 + htmlBio: p.htmlBio ?? undefined, 346 + followerCount: countMap[p.did]?.follower_count ?? 0, 347 + followingCount: countMap[p.did]?.following_count ?? 0, 348 + serverOrigin: p.serverOrigin ?? undefined, 349 + customFields: Object.entries(p.customFields).map(([key, value]) => ({ 350 + key, 351 + value: String(value) 352 + })) 353 + })) 228 354 }) 229 355 } 230 356 })