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

WIP social graph feature

+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 })