Monorepo for Tangled tangled.org
2

Configure Feed

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

appview/{db,state}: delete all duplicate records on delete

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jun 25, 2026, 2:51 AM +0900) commit 70893649 parent f94aa9e1 change-id slxyrlzn
+159 -99
+20 -23
appview/db/follow.go
··· 6 6 "strings" 7 7 "time" 8 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/appview/models" 10 11 "tangled.org/core/orm" 11 12 ) ··· 16 17 return err 17 18 } 18 19 19 - // Get a follow record 20 - func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 21 - query := `select did, subject_did, created, rkey from follows where did = ? and subject_did = ?` 22 - row := e.QueryRow(query, userDid, subjectDid) 23 - 24 - var follow models.Follow 25 - var followedAt string 26 - err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 20 + // Remove a follow 21 + func DeleteFollow(e Execer, did, subjectDid syntax.DID) ([]syntax.ATURI, error) { 22 + var deleted []syntax.ATURI 23 + rows, err := e.Query( 24 + `delete from follows 25 + where did = ? and subject_did = ? 26 + returning at_uri`, 27 + did, 28 + subjectDid, 29 + ) 27 30 if err != nil { 28 - return nil, err 31 + return nil, fmt.Errorf("deleting follows: %w", err) 29 32 } 33 + defer rows.Close() 30 34 31 - followedAtTime, err := time.Parse(time.RFC3339, followedAt) 32 - if err != nil { 33 - log.Println("unable to determine followed at time") 34 - follow.FollowedAt = time.Now() 35 - } else { 36 - follow.FollowedAt = followedAtTime 35 + for rows.Next() { 36 + var aturi syntax.ATURI 37 + if err := rows.Scan(&aturi); err != nil { 38 + return nil, fmt.Errorf("scanning at_uri: %w", err) 39 + } 40 + deleted = append(deleted, aturi) 37 41 } 38 - 39 - return &follow, nil 40 - } 41 - 42 - // Remove a follow 43 - func DeleteFollow(e Execer, userDid, subjectDid string) error { 44 - _, err := e.Exec(`delete from follows where did = ? and subject_did = ?`, userDid, subjectDid) 45 - return err 42 + return deleted, nil 46 43 } 47 44 48 45 // Remove a follow
+23 -3
appview/db/reaction.go
··· 43 43 } 44 44 45 45 // Remove a reaction 46 - func DeleteReaction(e Execer, did string, subjectAt syntax.ATURI, kind models.ReactionKind) error { 47 - _, err := e.Exec(`delete from reactions where did = ? and subject_at = ? and kind = ?`, did, subjectAt, kind) 48 - return err 46 + func DeleteReaction(e Execer, did syntax.DID, subjectAt syntax.ATURI, kind models.ReactionKind) ([]syntax.ATURI, error) { 47 + var deleted []syntax.ATURI 48 + rows, err := e.Query( 49 + `delete from reactions 50 + where did = ? and subject_at = ? and kind = ? 51 + returning at_uri`, 52 + did, 53 + subjectAt, 54 + kind, 55 + ) 56 + if err != nil { 57 + return nil, fmt.Errorf("deleting stars: %w", err) 58 + } 59 + defer rows.Close() 60 + 61 + for rows.Next() { 62 + var aturi syntax.ATURI 63 + if err := rows.Scan(&aturi); err != nil { 64 + return nil, fmt.Errorf("scanning at_uri: %w", err) 65 + } 66 + deleted = append(deleted, aturi) 67 + } 68 + return deleted, nil 49 69 } 50 70 51 71 // Remove a reaction
+26 -31
appview/db/star.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 - "log" 6 6 "slices" 7 7 "strings" 8 8 "time" 9 9 10 + "github.com/bluesky-social/indigo/atproto/syntax" 10 11 "tangled.org/core/appview/models" 11 12 "tangled.org/core/appview/pagination" 12 13 "tangled.org/core/orm" ··· 22 23 star.Rkey, 23 24 ) 24 25 return err 25 - } 26 - 27 - // Get a star record 28 - func GetStar(e Execer, did string, subject string) (*models.Star, error) { 29 - query := ` 30 - select did, subject_type, subject, created, rkey 31 - from stars 32 - where did = ? and subject = ?` 33 - row := e.QueryRow(query, did, subject) 34 - 35 - var star models.Star 36 - var created string 37 - err := row.Scan(&star.Did, &star.SubjectType, &star.Subject, &created, &star.Rkey) 38 - if err != nil { 39 - return nil, err 40 - } 41 - 42 - createdAtTime, err := time.Parse(time.RFC3339, created) 43 - if err != nil { 44 - log.Println("unable to determine followed at time") 45 - star.Created = time.Now() 46 - } else { 47 - star.Created = createdAtTime 48 - } 49 - 50 - return &star, nil 51 26 } 52 27 53 28 func GetStars(e Execer, subject string, page pagination.Page) ([]models.Star, error) { ··· 82 57 return stars, rows.Err() 83 58 } 84 59 85 - // Remove a star 86 - func DeleteStar(e Execer, did string, subject string) error { 87 - _, err := e.Exec(`delete from stars where did = ? and subject = ?`, did, subject) 88 - return err 60 + // Remove all stars from given user to subject 61 + func DeleteStars(tx *sql.Tx, did syntax.DID, subject string) ([]syntax.ATURI, error) { 62 + var deleted []syntax.ATURI 63 + rows, err := tx.Query( 64 + `delete from stars 65 + where did = ? and subject = ? 66 + returning at_uri`, 67 + did, 68 + subject, 69 + ) 70 + if err != nil { 71 + return nil, fmt.Errorf("deleting stars: %w", err) 72 + } 73 + defer rows.Close() 74 + 75 + for rows.Next() { 76 + var aturi syntax.ATURI 77 + if err := rows.Scan(&aturi); err != nil { 78 + return nil, fmt.Errorf("scanning at_uri: %w", err) 79 + } 80 + deleted = append(deleted, aturi) 81 + } 82 + 83 + return deleted, nil 89 84 } 90 85 91 86 // Remove a star
+34 -15
appview/state/follow.go
··· 5 5 "time" 6 6 7 7 comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 8 9 lexutil "github.com/bluesky-social/indigo/lex/util" 9 10 "tangled.org/core/api/tangled" 10 11 "tangled.org/core/appview/db" ··· 88 89 89 90 return 90 91 case http.MethodDelete: 91 - // find the record in the db 92 - follow, err := db.GetFollow(s.db, currentUser.Did, subjectIdent.DID.String()) 92 + tx, err := s.db.BeginTx(r.Context(), nil) 93 93 if err != nil { 94 - l.Error("failed to get follow relationship", "err", err) 94 + l.Error("failed to start transaction", "err", err) 95 95 return 96 96 } 97 - 98 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 99 - Collection: tangled.GraphFollowNSID, 100 - Repo: currentUser.Did, 101 - Rkey: follow.Rkey, 102 - }) 97 + defer tx.Rollback() 103 98 99 + follows, err := db.DeleteFollow(tx, syntax.DID(currentUser.Did), subjectIdent.DID) 104 100 if err != nil { 105 - l.Error("failed to unfollow", "err", err) 101 + l.Error("failed to delete follows from db", "err", err) 106 102 return 107 103 } 108 104 109 - err = db.DeleteFollowByRkey(s.db, currentUser.Did, follow.Rkey) 105 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 106 + for _, followAt := range follows { 107 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 108 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 109 + Collection: tangled.GraphFollowNSID, 110 + Rkey: followAt.RecordKey().String(), 111 + }, 112 + }) 113 + } 114 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 115 + Repo: currentUser.Did, 116 + Writes: writes, 117 + }) 110 118 if err != nil { 111 - l.Warn("failed to delete follow from DB", "err", err) 112 - // this is not an issue, the firehose event might have already done this 119 + l.Error("failed to delete follows from PDS", "err", err) 120 + return 113 121 } 114 122 123 + if err := tx.Commit(); err != nil { 124 + l.Error("failed to commit transaction", "err", err) 125 + // The record was deleted from the PDS but the local rollback kept it. 126 + // Ingester will backfill the missed operation 127 + } 128 + 129 + s.notifier.DeleteFollow(r.Context(), &models.Follow{ 130 + UserDid: currentUser.Did, 131 + SubjectDid: subjectIdent.DID.String(), 132 + // Rkey 133 + // FollowedAt 134 + }) 135 + 115 136 followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 116 137 if err != nil { 117 138 l.Error("failed to get follow stats", "err", err) ··· 122 143 FollowStatus: models.IsNotFollowing, 123 144 FollowersCount: followStats.Followers, 124 145 }) 125 - 126 - s.notifier.DeleteFollow(r.Context(), follow) 127 146 128 147 return 129 148 }
+24 -12
appview/state/reaction.go
··· 91 91 92 92 return 93 93 case http.MethodDelete: 94 - reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind) 94 + tx, err := s.db.BeginTx(r.Context(), nil) 95 + if err != nil { 96 + l.Error("failed to start transaction", "err", err) 97 + } 98 + defer tx.Rollback() 99 + 100 + reactions, err := db.DeleteReaction(tx, syntax.DID(currentUser.Did), subjectUri, reactionKind) 95 101 if err != nil { 96 - l.Error("failed to get reaction relationship", "did", currentUser.Did, "subjectUri", subjectUri, "err", err) 102 + l.Error("failed to delete reactions from db", "err", err) 97 103 return 98 104 } 99 105 100 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 101 - Collection: tangled.FeedReactionNSID, 102 - Repo: currentUser.Did, 103 - Rkey: reaction.Rkey, 106 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 107 + for _, reactionAt := range reactions { 108 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 109 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 110 + Collection: tangled.FeedReactionNSID, 111 + Rkey: reactionAt.RecordKey().String(), 112 + }, 113 + }) 114 + } 115 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 116 + Repo: currentUser.Did, 117 + Writes: writes, 104 118 }) 105 - 106 119 if err != nil { 107 - l.Error("failed to remove reaction", "err", err) 120 + l.Error("failed to delete reactions from PDS", "err", err) 108 121 return 109 122 } 110 123 111 - err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey) 112 - if err != nil { 113 - l.Warn("failed to delete reaction from DB", "err", err) 114 - // this is not an issue, the firehose event might have already done this 124 + if err := tx.Commit(); err != nil { 125 + l.Error("failed to commit transaction", "err", err) 126 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 115 127 } 116 128 117 129 reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
+32 -15
appview/state/star.go
··· 126 126 127 127 return 128 128 case http.MethodDelete: 129 - // find the record in the db 130 - star, err := db.GetStar(s.db, currentUser.Did, subjectKey) 129 + tx, err := s.db.BeginTx(r.Context(), nil) 131 130 if err != nil { 132 - l.Error("failed to get star relationship", "err", err) 131 + l.Error("failed to start transaction", "err", err) 132 + } 133 + defer tx.Rollback() 134 + 135 + stars, err := db.DeleteStars(tx, syntax.DID(currentUser.Did), subjectKey) 136 + if err != nil { 137 + l.Error("failed to delete stars from db", "err", err) 133 138 return 134 139 } 135 140 136 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 137 - Collection: tangled.FeedStarNSID, 138 - Repo: currentUser.Did, 139 - Rkey: star.Rkey, 141 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 142 + for _, starAt := range stars { 143 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 144 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 145 + Collection: tangled.FeedStarNSID, 146 + Rkey: starAt.RecordKey().String(), 147 + }, 148 + }) 149 + } 150 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 151 + Repo: currentUser.Did, 152 + Writes: writes, 140 153 }) 141 - 142 154 if err != nil { 143 - l.Error("failed to unstar", "err", err) 155 + l.Error("failed to delete stars from PDS", "err", err) 144 156 return 145 157 } 146 158 147 - err = db.DeleteStarByRkey(s.db, currentUser.Did, star.Rkey) 148 - if err != nil { 149 - l.Warn("failed to delete star from DB", "err", err) 150 - // this is not an issue, the firehose event might have already done this 159 + if err := tx.Commit(); err != nil { 160 + l.Error("failed to commit transaction", "err", err) 161 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 151 162 } 152 163 164 + s.notifier.DeleteStar(r.Context(), &models.Star{ 165 + Did: currentUser.Did, 166 + SubjectType: subjectType, 167 + Subject: subjectKey, 168 + // Rkey 169 + // Created 170 + }) 171 + 153 172 starCount, err := db.GetStarCount(s.db, subjectType, subjectKey) 154 173 if err != nil { 155 174 l.Error("failed to get star count", "subject", subjectKey, "err", err) 156 175 return 157 176 } 158 - 159 - s.notifier.DeleteStar(r.Context(), star) 160 177 161 178 s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 162 179 IsStarred: false,