+13
-1
CLAUDE.md
+13
-1
CLAUDE.md
···
179
- XRPC server implementation
180
- Database patterns and migrations
181
- Post storage and retrieval
182
-
- Account management
183
- ID resolution
184
185
- **Client**: `packages/client/CLAUDE.md`
···
262
- ✅ User authentication flow
263
- ✅ Basic UI components (Header, PostFeed, UserMenu)
264
- ✅ Lexicon type generation
265
266
### Planned Features
267
- **Job Queue & Workers**: Background processing for posts, firehose consumption, timelines
···
179
- XRPC server implementation
180
- Database patterns and migrations
181
- Post storage and retrieval
182
+
- Social graph (follow relationships)
183
+
- Profile management (wafrn-specific + caching)
184
+
- Account management (operational data)
185
- ID resolution
186
187
- **Client**: `packages/client/CLAUDE.md`
···
264
- ✅ User authentication flow
265
- ✅ Basic UI components (Header, PostFeed, UserMenu)
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
277
278
### Planned Features
279
- **Job Queue & Workers**: Background processing for posts, firehose consumption, timelines
+2
-9
lexicons.json
+2
-9
lexicons.json
+54
lexicons/app/wafrn/actor/defs.json
+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
+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
+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
+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
+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
+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
+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
+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
+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
+9
packages/lexicon/index.ts
···
1
export * as AppWafrnContentCachePost from "./types/app/wafrn/content/cachePost.js";
2
export * as AppWafrnContentDefs from "./types/app/wafrn/content/defs.js";
3
export * as AppWafrnContentGetFeed from "./types/app/wafrn/content/getFeed.js";
4
export * as AppWafrnContentPrivatePost from "./types/app/wafrn/content/privatePost.js";
5
export * as AppWafrnContentPublicPost from "./types/app/wafrn/content/publicPost.js";
···
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";
4
export * as AppWafrnContentCachePost from "./types/app/wafrn/content/cachePost.js";
5
export * as AppWafrnContentDefs from "./types/app/wafrn/content/defs.js";
6
export * as AppWafrnContentGetFeed from "./types/app/wafrn/content/getFeed.js";
7
export * as AppWafrnContentPrivatePost from "./types/app/wafrn/content/privatePost.js";
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+55
-9
packages/server/CLAUDE.md
···
69
│ ├── lib/
70
│ │ ├── env.ts # Environment variable validation
71
│ │ ├── account.ts # Account management logic
72
│ │ ├── xrpcServer.ts # XRPC server instance and configuration
73
│ │ └── idResolver.ts # ATProto identity resolution
74
│ └── db/
···
78
│ ├── 1761580548730_accounts.ts
79
│ ├── 1762114354466_public_posts.ts
80
│ ├── 1762179949540_private_posts.ts
81
-
│ └── 1762179953814_tags.ts
82
├── data/ # SQLite database files
83
├── package.json # Package dependencies and scripts
84
├── tsconfig.json # TypeScript configuration
···
105
- Retrieve posts by account or tag
106
- Tag management for posts
107
108
-
2. **Account Management**:
109
-
- Upsert account records (DID, handle, metadata)
110
- Query account information
111
- Account existence validation
112
113
-
3. **Identity Resolution**:
114
- Resolve ATProto DIDs to handles
115
- Resolve handles to DIDs
116
- PDS discovery for ATProto accounts
···
120
121
### Database Architecture
122
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
128
129
**Key Patterns:**
130
- Migrations run automatically on server start via `migrateDBToLatest()`
···
132
- Schema types auto-generated via `kysely-codegen` - regenerate after schema changes
133
- WAL mode enabled for better concurrent access
134
- TIDs (Timestamp IDs) used for post identifiers (ATProto standard)
135
136
### Environment Variables
137
···
69
│ ├── lib/
70
│ │ ├── env.ts # Environment variable validation
71
│ │ ├── account.ts # Account management logic
72
+
│ │ ├── follow.ts # Follow relationship management
73
+
│ │ ├── profile.ts # Profile management (wafrn-specific + cache)
74
│ │ ├── xrpcServer.ts # XRPC server instance and configuration
75
│ │ └── idResolver.ts # ATProto identity resolution
76
│ └── db/
···
80
│ ├── 1761580548730_accounts.ts
81
│ ├── 1762114354466_public_posts.ts
82
│ ├── 1762179949540_private_posts.ts
83
+
│ ├── 1762179953814_tags.ts
84
+
│ └── 1762527898283_social_graph.ts
85
├── data/ # SQLite database files
86
├── package.json # Package dependencies and scripts
87
├── tsconfig.json # TypeScript configuration
···
108
- Retrieve posts by account or tag
109
- Tag management for posts
110
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)
124
- Query account information
125
- Account existence validation
126
+
- Operational status tracking (active, roles, moderation status)
127
128
+
5. **Identity Resolution**:
129
- Resolve ATProto DIDs to handles
130
- Resolve handles to DIDs
131
- PDS discovery for ATProto accounts
···
135
136
### Database Architecture
137
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
172
173
**Key Patterns:**
174
- Migrations run automatically on server start via `migrateDBToLatest()`
···
176
- Schema types auto-generated via `kysely-codegen` - regenerate after schema changes
177
- WAL mode enabled for better concurrent access
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
181
182
### Environment Variables
183
+31
packages/server/src/db/schema.d.ts
+31
packages/server/src/db/schema.d.ts
···
19
status: string | null;
20
}
21
22
export interface PrivatePosts {
23
author_did: string;
24
created_at: Generated<number>;
···
30
visibility: Generated<string>;
31
}
32
33
export interface PublicPosts {
34
author_did: string;
35
content_html: string;
···
54
55
export interface DB {
56
accounts: Accounts;
57
private_posts: PrivatePosts;
58
public_post_tags: PublicPostTags;
59
public_posts: PublicPosts;
60
tags: Tags;
···
19
status: string | null;
20
}
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
+
37
export interface PrivatePosts {
38
author_did: string;
39
created_at: Generated<number>;
···
45
visibility: Generated<string>;
46
}
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
+
61
export interface PublicPosts {
62
author_did: string;
63
content_html: string;
···
82
83
export interface DB {
84
accounts: Accounts;
85
+
follow_counts: FollowCounts;
86
+
follows: Follows;
87
private_posts: PrivatePosts;
88
+
profiles: Profiles;
89
public_post_tags: PublicPostTags;
90
public_posts: PublicPosts;
91
tags: Tags;
+183
packages/server/src/lib/follow.ts
+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
+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
+128
-2
packages/server/src/lib/xrpcServer.ts
···
4
import {
5
AppWafrnContentCachePost,
6
AppWafrnContentDefs,
7
-
AppWafrnContentGetFeed
8
} from '@watproto/lexicon'
9
import { didDocResolver } from './idResolver'
10
import env from './env'
11
import { db } from '@api/db/db'
12
-
import { is, type ResourceUri } from '@atcute/lexicons'
13
14
const jwtVerifier = new ServiceJwtVerifier({
15
resolver: didDocResolver,
···
225
226
return json({
227
feed: posts
228
})
229
}
230
})
···
4
import {
5
AppWafrnContentCachePost,
6
AppWafrnContentDefs,
7
+
AppWafrnContentGetFeed,
8
+
AppWafrnGraphCacheFollow,
9
+
AppWafrnGraphDeleteFollow,
10
+
AppWafrnGraphGetFollowers,
11
+
AppWafrnGraphGetFollows,
12
+
AppWafrnActorGetProfiles
13
} from '@watproto/lexicon'
14
import { didDocResolver } from './idResolver'
15
import env from './env'
16
import { db } from '@api/db/db'
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'
26
27
const jwtVerifier = new ServiceJwtVerifier({
28
resolver: didDocResolver,
···
238
239
return json({
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
+
}))
354
})
355
}
356
})