···120120 return err
121121 }
122122123123+ _, err = tx.ExecContext(ctx, `
124124+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds)
125125+ SELECT
126126+ MIN(f.user_did, f.target_did),
127127+ MAX(f.user_did, f.target_did),
128128+ 0.5,
129129+ 0
130130+ FROM follows f
131131+ ON CONFLICT(user_a, user_b) DO UPDATE SET
132132+ jaccard = jaccard + 0.5
133133+ `)
134134+ if err != nil {
135135+ return err
136136+ }
137137+123138 e.logger.Info("user similarity computed")
124139 return tx.Commit()
125140}
+10
internal/db/db.go
···169169 computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
170170 PRIMARY KEY (user_did, feed_url, article_url)
171171 )`,
172172+ `CREATE TABLE IF NOT EXISTS follows (
173173+ user_did TEXT NOT NULL REFERENCES users(did),
174174+ target_did TEXT NOT NULL,
175175+ uri TEXT,
176176+ cid TEXT,
177177+ followed_at DATETIME,
178178+ PRIMARY KEY (user_did, target_did)
179179+ )`,
172180 `CREATE TABLE IF NOT EXISTS oauth_auth_requests (
173181 state TEXT PRIMARY KEY,
174182 data TEXT NOT NULL
···187195 `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
188196 `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
189197 `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
198198+ `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
199199+ `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
190200}
+171
internal/db/follow.go
···11+package db
22+33+import (
44+ "context"
55+ "database/sql"
66+ "time"
77+)
88+99+type Follow struct {
1010+ UserDID string
1111+ TargetDID string
1212+ URI sql.NullString
1313+ CID sql.NullString
1414+ FollowedAt sql.NullTime
1515+}
1616+1717+func (db *DB) UpsertFollow(ctx context.Context, userDID, targetDID, uri, cid string) error {
1818+ _, err := db.ExecContext(ctx, `
1919+ INSERT INTO follows (user_did, target_did, uri, cid, followed_at)
2020+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
2121+ ON CONFLICT(user_did, target_did) DO UPDATE SET
2222+ uri = excluded.uri,
2323+ cid = excluded.cid
2424+ `, userDID, targetDID, uriOrNil("", uri), uriOrNil("", cid))
2525+ return err
2626+}
2727+2828+func (db *DB) DeleteFollow(ctx context.Context, userDID, targetDID string) error {
2929+ _, err := db.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID)
3030+ return err
3131+}
3232+3333+func (db *DB) DeleteFollowByURI(ctx context.Context, uri string) error {
3434+ _, err := db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri)
3535+ return err
3636+}
3737+3838+func (db *DB) ListFollows(ctx context.Context, userDID string, limit, offset int) ([]*Follow, error) {
3939+ rows, err := db.QueryContext(ctx, `
4040+ SELECT user_did, target_did, uri, cid, followed_at
4141+ FROM follows WHERE user_did = ?
4242+ ORDER BY followed_at DESC
4343+ LIMIT ? OFFSET ?
4444+ `, userDID, limit, offset)
4545+ if err != nil {
4646+ return nil, err
4747+ }
4848+ defer rows.Close()
4949+5050+ var follows []*Follow
5151+ for rows.Next() {
5252+ f := &Follow{}
5353+ if err := rows.Scan(&f.UserDID, &f.TargetDID, &f.URI, &f.CID, &f.FollowedAt); err != nil {
5454+ return nil, err
5555+ }
5656+ follows = append(follows, f)
5757+ }
5858+ return follows, rows.Err()
5959+}
6060+6161+func (db *DB) ListFollowers(ctx context.Context, targetDID string, limit, offset int) ([]*Follow, error) {
6262+ rows, err := db.QueryContext(ctx, `
6363+ SELECT user_did, target_did, uri, cid, followed_at
6464+ FROM follows WHERE target_did = ?
6565+ ORDER BY followed_at DESC
6666+ LIMIT ? OFFSET ?
6767+ `, targetDID, limit, offset)
6868+ if err != nil {
6969+ return nil, err
7070+ }
7171+ defer rows.Close()
7272+7373+ var follows []*Follow
7474+ for rows.Next() {
7575+ f := &Follow{}
7676+ if err := rows.Scan(&f.UserDID, &f.TargetDID, &f.URI, &f.CID, &f.FollowedAt); err != nil {
7777+ return nil, err
7878+ }
7979+ follows = append(follows, f)
8080+ }
8181+ return follows, rows.Err()
8282+}
8383+8484+func (db *DB) IsFollowing(ctx context.Context, userDID, targetDID string) (bool, error) {
8585+ var exists int
8686+ err := db.QueryRowContext(ctx, `
8787+ SELECT 1 FROM follows WHERE user_did = ? AND target_did = ?
8888+ `, userDID, targetDID).Scan(&exists)
8989+ if err == sql.ErrNoRows {
9090+ return false, nil
9191+ }
9292+ if err != nil {
9393+ return false, err
9494+ }
9595+ return true, nil
9696+}
9797+9898+func (db *DB) GetFollowDIDs(ctx context.Context, userDID string) ([]string, error) {
9999+ rows, err := db.QueryContext(ctx, `
100100+ SELECT target_did FROM follows WHERE user_did = ?
101101+ `, userDID)
102102+ if err != nil {
103103+ return nil, err
104104+ }
105105+ defer rows.Close()
106106+107107+ var dids []string
108108+ for rows.Next() {
109109+ var did string
110110+ if err := rows.Scan(&did); err != nil {
111111+ return nil, err
112112+ }
113113+ dids = append(dids, did)
114114+ }
115115+ return dids, rows.Err()
116116+}
117117+118118+func (db *DB) SyncFollows(ctx context.Context, userDID string, activeFollows map[string]Follow) error {
119119+ tx, err := db.BeginTx(ctx, nil)
120120+ if err != nil {
121121+ return err
122122+ }
123123+ defer func() { _ = tx.Rollback() }()
124124+125125+ rows, err := tx.QueryContext(ctx, `SELECT target_did, uri, cid, followed_at FROM follows WHERE user_did = ?`, userDID)
126126+ if err != nil {
127127+ return err
128128+ }
129129+130130+ existing := make(map[string]bool)
131131+ for rows.Next() {
132132+ var targetDID string
133133+ var uri, cid sql.NullString
134134+ var followedAt sql.NullTime
135135+ if err := rows.Scan(&targetDID, &uri, &cid, &followedAt); err != nil {
136136+ rows.Close()
137137+ return err
138138+ }
139139+ existing[targetDID] = true
140140+ }
141141+ rows.Close()
142142+143143+ for targetDID := range existing {
144144+ if _, ok := activeFollows[targetDID]; !ok {
145145+ if _, err := tx.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID); err != nil {
146146+ return err
147147+ }
148148+ }
149149+ }
150150+151151+ for targetDID, f := range activeFollows {
152152+ if !existing[targetDID] {
153153+ var followedAt any
154154+ if f.FollowedAt.Valid {
155155+ followedAt = f.FollowedAt.Time
156156+ } else {
157157+ followedAt = time.Now()
158158+ }
159159+ _, err := tx.ExecContext(ctx, `
160160+ INSERT INTO follows (user_did, target_did, uri, cid, followed_at)
161161+ VALUES (?, ?, ?, ?, ?)
162162+ ON CONFLICT(user_did, target_did) DO UPDATE SET uri = excluded.uri, cid = excluded.cid
163163+ `, userDID, targetDID, f.URI, f.CID, followedAt)
164164+ if err != nil {
165165+ return err
166166+ }
167167+ }
168168+ }
169169+170170+ return tx.Commit()
171171+}