Implement orphan cleanup for sync operationsUnverified
947a4c9 parent: 0669e18 modified
internal/atproto/stream_handler.go +4 -0 | @@ -202,6 +202,8 @@ func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) er | ||
| 202 | 202 | return h.articles.UpdateAnnotation(ctx, a) |
| 203 | 203 | |
| 204 | 204 | case actionDelete: |
| 205 | + // Margin notes are converted to annotations; their URI is tracked | |
| 206 | + // by sync so orphan cleanup handles deletion during backfill. | |
| 205 | 207 | } |
| 206 | 208 | return nil |
| 207 | 209 | } |
| @@ -252,6 +254,8 @@ func (h *StreamDBHandler) handleSkyreaderSubscription(ctx context.Context, event | ||
| 252 | 254 | return err |
| 253 | 255 | |
| 254 | 256 | case actionDelete: |
| 257 | + // Skyreader subscriptions are imported into glean subscriptions; | |
| 258 | + // sync's orphan cleanup handles deletion during backfill. | |
| 255 | 259 | } |
| 256 | 260 | return nil |
| 257 | 261 | } |
| @@ -202,6 +202,8 @@ func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) er | |||
| 202 | return h.articles.UpdateAnnotation(ctx, a) | 202 | return h.articles.UpdateAnnotation(ctx, a) |
| 203 | 203 | ||
| 204 | case actionDelete: | 204 | case actionDelete: |
| 205 | + // Margin notes are converted to annotations; their URI is tracked | ||
| 206 | + // by sync so orphan cleanup handles deletion during backfill. | ||
| 205 | } | 207 | } |
| 206 | return nil | 208 | return nil |
| 207 | } | 209 | } |
| @@ -252,6 +254,8 @@ func (h *StreamDBHandler) handleSkyreaderSubscription(ctx context.Context, event | |||
| 252 | return err | 254 | return err |
| 253 | 255 | ||
| 254 | case actionDelete: | 256 | case actionDelete: |
| 257 | + // Skyreader subscriptions are imported into glean subscriptions; | ||
| 258 | + // sync's orphan cleanup handles deletion during backfill. | ||
| 255 | } | 259 | } |
| 256 | return nil | 260 | return nil |
| 257 | } | 261 | } |
modified
internal/atproto/sync.go +73 -70 | @@ -33,21 +33,15 @@ func NewSync(articles *db.ArticleStore, users *db.UserStore, client *Client, log | ||
| 33 | 33 | func (s *Sync) Run(ctx context.Context, userDID string) error { |
| 34 | 34 | s.logger.Info("syncing from PDS", "did", userDID) |
| 35 | 35 | |
| 36 | - if err := s.syncCollection(ctx, userDID, CollectionSubscription, s.batchReconcileSubscriptions); err != nil { | |
| 36 | + if err := s.syncSubscriptions(ctx, userDID); err != nil { | |
| 37 | 37 | s.logger.Error("sync subscriptions failed", "error", err, "did", userDID) |
| 38 | 38 | } |
| 39 | - if err := s.syncCollection(ctx, userDID, CollectionSkyreaderSubscription, s.batchReconcileSkyreaderSubscriptions); err != nil { | |
| 40 | - s.logger.Error("sync skyreader subscriptions failed", "error", err, "did", userDID) | |
| 41 | - } | |
| 42 | - if err := s.syncCollection(ctx, userDID, CollectionLike, s.batchReconcileLikes); err != nil { | |
| 39 | + if err := s.syncLikes(ctx, userDID); err != nil { | |
| 43 | 40 | s.logger.Error("sync likes failed", "error", err, "did", userDID) |
| 44 | 41 | } |
| 45 | - if err := s.syncCollection(ctx, userDID, CollectionAnnotation, s.batchReconcileAnnotations); err != nil { | |
| 42 | + if err := s.syncAnnotations(ctx, userDID); err != nil { | |
| 46 | 43 | s.logger.Error("sync annotations failed", "error", err, "did", userDID) |
| 47 | 44 | } |
| 48 | - if err := s.syncCollection(ctx, userDID, CollectionMarginNote, s.batchReconcileMarginNotes); err != nil { | |
| 49 | - s.logger.Error("sync margin notes failed", "error", err, "did", userDID) | |
| 50 | - } | |
| 51 | 45 | if err := s.syncFollows(ctx, userDID); err != nil { |
| 52 | 46 | s.logger.Error("sync follows failed", "error", err, "did", userDID) |
| 53 | 47 | } |
| @@ -55,13 +49,13 @@ func (s *Sync) Run(ctx context.Context, userDID string) error { | ||
| 55 | 49 | return nil |
| 56 | 50 | } |
| 57 | 51 | |
| 58 | -func (s *Sync) syncCollection(ctx context.Context, userDID, collection string, fn func(ctx context.Context, userDID string, records []Record) error) error { | |
| 52 | +func (s *Sync) listRecords(ctx context.Context, userDID, collection string) ([]Record, error) { | |
| 59 | 53 | var allRecords []Record |
| 60 | 54 | cursor := "" |
| 61 | 55 | for { |
| 62 | 56 | records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) |
| 63 | 57 | if err != nil { |
| 64 | - return err | |
| 58 | + return nil, err | |
| 65 | 59 | } |
| 66 | 60 | allRecords = append(allRecords, records...) |
| 67 | 61 | if next == "" || len(records) == 0 { |
| @@ -69,17 +63,24 @@ func (s *Sync) syncCollection(ctx context.Context, userDID, collection string, f | ||
| 69 | 63 | } |
| 70 | 64 | cursor = next |
| 71 | 65 | } |
| 72 | - if len(allRecords) == 0 { | |
| 73 | - return nil | |
| 74 | - } | |
| 75 | - return fn(ctx, userDID, allRecords) | |
| 66 | + return allRecords, nil | |
| 76 | 67 | } |
| 77 | 68 | |
| 78 | -func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string, records []Record) error { | |
| 69 | +func (s *Sync) syncSubscriptions(ctx context.Context, userDID string) error { | |
| 70 | + gleanRecs, err := s.listRecords(ctx, userDID, CollectionSubscription) | |
| 71 | + if err != nil { | |
| 72 | + return err | |
| 73 | + } | |
| 74 | + skyRecs, err := s.listRecords(ctx, userDID, CollectionSkyreaderSubscription) | |
| 75 | + if err != nil { | |
| 76 | + return err | |
| 77 | + } | |
| 78 | + | |
| 79 | 79 | var feeds []*db.Feed |
| 80 | 80 | var subs []db.SubData |
| 81 | + activeFeedURLs := make(map[string]bool) | |
| 81 | 82 | |
| 82 | - for _, r := range records { | |
| 83 | + for _, r := range gleanRecs { | |
| 83 | 84 | var rec SubscriptionRecord |
| 84 | 85 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 85 | 86 | continue |
| @@ -87,6 +88,7 @@ func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string, | ||
| 87 | 88 | if rec.FeedURL == "" { |
| 88 | 89 | continue |
| 89 | 90 | } |
| 91 | + activeFeedURLs[rec.FeedURL] = true | |
| 90 | 92 | feeds = append(feeds, &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}) |
| 91 | 93 | subs = append(subs, db.SubData{ |
| 92 | 94 | FeedURL: rec.FeedURL, |
| @@ -97,19 +99,7 @@ func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string, | ||
| 97 | 99 | }) |
| 98 | 100 | } |
| 99 | 101 | |
| 100 | - if len(feeds) > 0 { | |
| 101 | - if err := s.articles.BatchUpsertFeeds(ctx, feeds); err != nil { | |
| 102 | - return fmt.Errorf("upsert feeds: %w", err) | |
| 103 | - } | |
| 104 | - } | |
| 105 | - return s.articles.BatchReconcileSubscriptions(ctx, userDID, subs) | |
| 106 | -} | |
| 107 | - | |
| 108 | -func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID string, records []Record) error { | |
| 109 | - var feeds []*db.Feed | |
| 110 | - var subs []db.SubData | |
| 111 | - | |
| 112 | - for _, r := range records { | |
| 102 | + for _, r := range skyRecs { | |
| 113 | 103 | var rec SkyreaderSubscriptionRecord |
| 114 | 104 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 115 | 105 | continue |
| @@ -117,6 +107,7 @@ func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID | ||
| 117 | 107 | if rec.FeedURL == "" { |
| 118 | 108 | continue |
| 119 | 109 | } |
| 110 | + activeFeedURLs[rec.FeedURL] = true | |
| 120 | 111 | feeds = append(feeds, &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title), SiteURL: db.NullStr(rec.SiteURL)}) |
| 121 | 112 | subs = append(subs, db.SubData{ |
| 122 | 113 | FeedURL: rec.FeedURL, |
| @@ -128,16 +119,23 @@ func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID | ||
| 128 | 119 | |
| 129 | 120 | if len(feeds) > 0 { |
| 130 | 121 | if err := s.articles.BatchUpsertFeeds(ctx, feeds); err != nil { |
| 131 | - return fmt.Errorf("failed to upsert feeds: %w", err) | |
| 122 | + return fmt.Errorf("upsert feeds: %w", err) | |
| 132 | 123 | } |
| 133 | 124 | } |
| 134 | - | |
| 135 | - return s.articles.BatchReconcileSubscriptions(ctx, userDID, subs) | |
| 125 | + if err := s.articles.BatchReconcileSubscriptions(ctx, userDID, subs); err != nil { | |
| 126 | + return err | |
| 127 | + } | |
| 128 | + return s.articles.DeleteOrphanedSubscriptions(ctx, userDID, activeFeedURLs) | |
| 136 | 129 | } |
| 137 | 130 | |
| 138 | -func (s *Sync) batchReconcileLikes(ctx context.Context, userDID string, records []Record) error { | |
| 139 | - var likes []*db.Like | |
| 131 | +func (s *Sync) syncLikes(ctx context.Context, userDID string) error { | |
| 132 | + records, err := s.listRecords(ctx, userDID, CollectionLike) | |
| 133 | + if err != nil { | |
| 134 | + return err | |
| 135 | + } | |
| 140 | 136 | |
| 137 | + var likes []*db.Like | |
| 138 | + activeURIs := make(map[string]bool) | |
| 141 | 139 | for _, r := range records { |
| 142 | 140 | var rec LikeRecord |
| 143 | 141 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| @@ -146,6 +144,7 @@ func (s *Sync) batchReconcileLikes(ctx context.Context, userDID string, records | ||
| 146 | 144 | if rec.FeedURL == "" || rec.ArticleURL == "" { |
| 147 | 145 | continue |
| 148 | 146 | } |
| 147 | + activeURIs[r.URI] = true | |
| 149 | 148 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) |
| 150 | 149 | likes = append(likes, &db.Like{ |
| 151 | 150 | URI: r.URI, |
| @@ -157,13 +156,26 @@ func (s *Sync) batchReconcileLikes(ctx context.Context, userDID string, records | ||
| 157 | 156 | }) |
| 158 | 157 | } |
| 159 | 158 | |
| 160 | - return s.articles.BatchCreateLikes(ctx, likes) | |
| 159 | + if err := s.articles.BatchCreateLikes(ctx, likes); err != nil { | |
| 160 | + return err | |
| 161 | + } | |
| 162 | + return s.articles.DeleteOrphanedLikes(ctx, userDID, activeURIs) | |
| 161 | 163 | } |
| 162 | 164 | |
| 163 | -func (s *Sync) batchReconcileAnnotations(ctx context.Context, userDID string, records []Record) error { | |
| 165 | +func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | |
| 166 | + annRecs, err := s.listRecords(ctx, userDID, CollectionAnnotation) | |
| 167 | + if err != nil { | |
| 168 | + return err | |
| 169 | + } | |
| 170 | + marginRecs, err := s.listRecords(ctx, userDID, CollectionMarginNote) | |
| 171 | + if err != nil { | |
| 172 | + return err | |
| 173 | + } | |
| 174 | + | |
| 164 | 175 | var annotations []*db.Annotation |
| 176 | + activeURIs := make(map[string]bool) | |
| 165 | 177 | |
| 166 | - for _, r := range records { | |
| 178 | + for _, r := range annRecs { | |
| 167 | 179 | var rec AnnotationRecord |
| 168 | 180 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 169 | 181 | continue |
| @@ -171,6 +183,7 @@ func (s *Sync) batchReconcileAnnotations(ctx context.Context, userDID string, re | ||
| 171 | 183 | if rec.FeedURL == "" || rec.ArticleURL == "" { |
| 172 | 184 | continue |
| 173 | 185 | } |
| 186 | + activeURIs[r.URI] = true | |
| 174 | 187 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) |
| 175 | 188 | a := &db.Annotation{ |
| 176 | 189 | URI: r.URI, |
| @@ -189,13 +202,7 @@ func (s *Sync) batchReconcileAnnotations(ctx context.Context, userDID string, re | ||
| 189 | 202 | annotations = append(annotations, a) |
| 190 | 203 | } |
| 191 | 204 | |
| 192 | - return s.articles.BatchCreateAnnotations(ctx, annotations) | |
| 193 | -} | |
| 194 | - | |
| 195 | -func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, records []Record) error { | |
| 196 | - var annotations []*db.Annotation | |
| 197 | - | |
| 198 | - for _, r := range records { | |
| 205 | + for _, r := range marginRecs { | |
| 199 | 206 | var rec MarginNoteRecord |
| 200 | 207 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 201 | 208 | continue |
| @@ -204,6 +211,7 @@ func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, re | ||
| 204 | 211 | if articleURL == "" { |
| 205 | 212 | continue |
| 206 | 213 | } |
| 214 | + activeURIs[r.URI] = true | |
| 207 | 215 | |
| 208 | 216 | feedURL := "" |
| 209 | 217 | if article, err := s.articles.GetArticleByURL(ctx, articleURL); err == nil { |
| @@ -224,41 +232,36 @@ func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, re | ||
| 224 | 232 | }) |
| 225 | 233 | } |
| 226 | 234 | |
| 227 | - return s.articles.BatchCreateAnnotations(ctx, annotations) | |
| 235 | + if err := s.articles.BatchCreateAnnotations(ctx, annotations); err != nil { | |
| 236 | + return err | |
| 237 | + } | |
| 238 | + return s.articles.DeleteOrphanedAnnotations(ctx, userDID, activeURIs) | |
| 228 | 239 | } |
| 229 | 240 | |
| 230 | 241 | func (s *Sync) syncFollows(ctx context.Context, userDID string) error { |
| 231 | 242 | activeFollows := make(map[string]db.Follow) |
| 232 | 243 | |
| 233 | 244 | for _, collection := range []string{CollectionBskyFollow, CollectionTangledFollow} { |
| 234 | - cursor := "" | |
| 235 | - for { | |
| 236 | - records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) | |
| 237 | - if err != nil { | |
| 238 | - return err | |
| 239 | - } | |
| 240 | - | |
| 241 | - for _, r := range records { | |
| 242 | - var rec FollowRecord | |
| 243 | - if err := json.Unmarshal(r.Value, &rec); err != nil { | |
| 244 | - continue | |
| 245 | - } | |
| 246 | - if rec.Subject == "" { | |
| 247 | - continue | |
| 248 | - } | |
| 245 | + records, err := s.listRecords(ctx, userDID, collection) | |
| 246 | + if err != nil { | |
| 247 | + return err | |
| 248 | + } | |
| 249 | 249 | |
| 250 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | |
| 251 | - activeFollows[rec.Subject] = db.Follow{ | |
| 252 | - URI: db.NullStr(r.URI), | |
| 253 | - CID: db.NullStr(r.CID), | |
| 254 | - FollowedAt: db.NullTime(t), | |
| 255 | - } | |
| 250 | + for _, r := range records { | |
| 251 | + var rec FollowRecord | |
| 252 | + if err := json.Unmarshal(r.Value, &rec); err != nil { | |
| 253 | + continue | |
| 254 | + } | |
| 255 | + if rec.Subject == "" { | |
| 256 | + continue | |
| 256 | 257 | } |
| 257 | 258 | |
| 258 | - if next == "" || len(records) == 0 { | |
| 259 | - break | |
| 259 | + t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | |
| 260 | + activeFollows[rec.Subject] = db.Follow{ | |
| 261 | + URI: db.NullStr(r.URI), | |
| 262 | + CID: db.NullStr(r.CID), | |
| 263 | + FollowedAt: db.NullTime(t), | |
| 260 | 264 | } |
| 261 | - cursor = next | |
| 262 | 265 | } |
| 263 | 266 | } |
| 264 | 267 | |
| @@ -33,21 +33,15 @@ func NewSync(articles *db.ArticleStore, users *db.UserStore, client *Client, log | |||
| 33 | func (s *Sync) Run(ctx context.Context, userDID string) error { | 33 | func (s *Sync) Run(ctx context.Context, userDID string) error { |
| 34 | s.logger.Info("syncing from PDS", "did", userDID) | 34 | s.logger.Info("syncing from PDS", "did", userDID) |
| 35 | 35 | ||
| 36 | - if err := s.syncCollection(ctx, userDID, CollectionSubscription, s.batchReconcileSubscriptions); err != nil { | 36 | + if err := s.syncSubscriptions(ctx, userDID); err != nil { |
| 37 | s.logger.Error("sync subscriptions failed", "error", err, "did", userDID) | 37 | s.logger.Error("sync subscriptions failed", "error", err, "did", userDID) |
| 38 | } | 38 | } |
| 39 | - if err := s.syncCollection(ctx, userDID, CollectionSkyreaderSubscription, s.batchReconcileSkyreaderSubscriptions); err != nil { | 39 | + if err := s.syncLikes(ctx, userDID); err != nil { |
| 40 | - s.logger.Error("sync skyreader subscriptions failed", "error", err, "did", userDID) | ||
| 41 | - } | ||
| 42 | - if err := s.syncCollection(ctx, userDID, CollectionLike, s.batchReconcileLikes); err != nil { | ||
| 43 | s.logger.Error("sync likes failed", "error", err, "did", userDID) | 40 | s.logger.Error("sync likes failed", "error", err, "did", userDID) |
| 44 | } | 41 | } |
| 45 | - if err := s.syncCollection(ctx, userDID, CollectionAnnotation, s.batchReconcileAnnotations); err != nil { | 42 | + if err := s.syncAnnotations(ctx, userDID); err != nil { |
| 46 | s.logger.Error("sync annotations failed", "error", err, "did", userDID) | 43 | s.logger.Error("sync annotations failed", "error", err, "did", userDID) |
| 47 | } | 44 | } |
| 48 | - if err := s.syncCollection(ctx, userDID, CollectionMarginNote, s.batchReconcileMarginNotes); err != nil { | ||
| 49 | - s.logger.Error("sync margin notes failed", "error", err, "did", userDID) | ||
| 50 | - } | ||
| 51 | if err := s.syncFollows(ctx, userDID); err != nil { | 45 | if err := s.syncFollows(ctx, userDID); err != nil { |
| 52 | s.logger.Error("sync follows failed", "error", err, "did", userDID) | 46 | s.logger.Error("sync follows failed", "error", err, "did", userDID) |
| 53 | } | 47 | } |
| @@ -55,13 +49,13 @@ func (s *Sync) Run(ctx context.Context, userDID string) error { | |||
| 55 | return nil | 49 | return nil |
| 56 | } | 50 | } |
| 57 | 51 | ||
| 58 | -func (s *Sync) syncCollection(ctx context.Context, userDID, collection string, fn func(ctx context.Context, userDID string, records []Record) error) error { | 52 | +func (s *Sync) listRecords(ctx context.Context, userDID, collection string) ([]Record, error) { |
| 59 | var allRecords []Record | 53 | var allRecords []Record |
| 60 | cursor := "" | 54 | cursor := "" |
| 61 | for { | 55 | for { |
| 62 | records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) | 56 | records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) |
| 63 | if err != nil { | 57 | if err != nil { |
| 64 | - return err | 58 | + return nil, err |
| 65 | } | 59 | } |
| 66 | allRecords = append(allRecords, records...) | 60 | allRecords = append(allRecords, records...) |
| 67 | if next == "" || len(records) == 0 { | 61 | if next == "" || len(records) == 0 { |
| @@ -69,17 +63,24 @@ func (s *Sync) syncCollection(ctx context.Context, userDID, collection string, f | |||
| 69 | } | 63 | } |
| 70 | cursor = next | 64 | cursor = next |
| 71 | } | 65 | } |
| 72 | - if len(allRecords) == 0 { | 66 | + return allRecords, nil |
| 73 | - return nil | ||
| 74 | - } | ||
| 75 | - return fn(ctx, userDID, allRecords) | ||
| 76 | } | 67 | } |
| 77 | 68 | ||
| 78 | -func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string, records []Record) error { | 69 | +func (s *Sync) syncSubscriptions(ctx context.Context, userDID string) error { |
| 70 | + gleanRecs, err := s.listRecords(ctx, userDID, CollectionSubscription) | ||
| 71 | + if err != nil { | ||
| 72 | + return err | ||
| 73 | + } | ||
| 74 | + skyRecs, err := s.listRecords(ctx, userDID, CollectionSkyreaderSubscription) | ||
| 75 | + if err != nil { | ||
| 76 | + return err | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | var feeds []*db.Feed | 79 | var feeds []*db.Feed |
| 80 | var subs []db.SubData | 80 | var subs []db.SubData |
| 81 | + activeFeedURLs := make(map[string]bool) | ||
| 81 | 82 | ||
| 82 | - for _, r := range records { | 83 | + for _, r := range gleanRecs { |
| 83 | var rec SubscriptionRecord | 84 | var rec SubscriptionRecord |
| 84 | if err := json.Unmarshal(r.Value, &rec); err != nil { | 85 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 85 | continue | 86 | continue |
| @@ -87,6 +88,7 @@ func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string, | |||
| 87 | if rec.FeedURL == "" { | 88 | if rec.FeedURL == "" { |
| 88 | continue | 89 | continue |
| 89 | } | 90 | } |
| 91 | + activeFeedURLs[rec.FeedURL] = true | ||
| 90 | feeds = append(feeds, &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}) | 92 | feeds = append(feeds, &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}) |
| 91 | subs = append(subs, db.SubData{ | 93 | subs = append(subs, db.SubData{ |
| 92 | FeedURL: rec.FeedURL, | 94 | FeedURL: rec.FeedURL, |
| @@ -97,19 +99,7 @@ func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string, | |||
| 97 | }) | 99 | }) |
| 98 | } | 100 | } |
| 99 | 101 | ||
| 100 | - if len(feeds) > 0 { | 102 | + for _, r := range skyRecs { |
| 101 | - if err := s.articles.BatchUpsertFeeds(ctx, feeds); err != nil { | ||
| 102 | - return fmt.Errorf("upsert feeds: %w", err) | ||
| 103 | - } | ||
| 104 | - } | ||
| 105 | - return s.articles.BatchReconcileSubscriptions(ctx, userDID, subs) | ||
| 106 | -} | ||
| 107 | - | ||
| 108 | -func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID string, records []Record) error { | ||
| 109 | - var feeds []*db.Feed | ||
| 110 | - var subs []db.SubData | ||
| 111 | - | ||
| 112 | - for _, r := range records { | ||
| 113 | var rec SkyreaderSubscriptionRecord | 103 | var rec SkyreaderSubscriptionRecord |
| 114 | if err := json.Unmarshal(r.Value, &rec); err != nil { | 104 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 115 | continue | 105 | continue |
| @@ -117,6 +107,7 @@ func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID | |||
| 117 | if rec.FeedURL == "" { | 107 | if rec.FeedURL == "" { |
| 118 | continue | 108 | continue |
| 119 | } | 109 | } |
| 110 | + activeFeedURLs[rec.FeedURL] = true | ||
| 120 | feeds = append(feeds, &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title), SiteURL: db.NullStr(rec.SiteURL)}) | 111 | feeds = append(feeds, &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title), SiteURL: db.NullStr(rec.SiteURL)}) |
| 121 | subs = append(subs, db.SubData{ | 112 | subs = append(subs, db.SubData{ |
| 122 | FeedURL: rec.FeedURL, | 113 | FeedURL: rec.FeedURL, |
| @@ -128,16 +119,23 @@ func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID | |||
| 128 | 119 | ||
| 129 | if len(feeds) > 0 { | 120 | if len(feeds) > 0 { |
| 130 | if err := s.articles.BatchUpsertFeeds(ctx, feeds); err != nil { | 121 | if err := s.articles.BatchUpsertFeeds(ctx, feeds); err != nil { |
| 131 | - return fmt.Errorf("failed to upsert feeds: %w", err) | 122 | + return fmt.Errorf("upsert feeds: %w", err) |
| 132 | } | 123 | } |
| 133 | } | 124 | } |
| 134 | - | 125 | + if err := s.articles.BatchReconcileSubscriptions(ctx, userDID, subs); err != nil { |
| 135 | - return s.articles.BatchReconcileSubscriptions(ctx, userDID, subs) | 126 | + return err |
| 127 | + } | ||
| 128 | + return s.articles.DeleteOrphanedSubscriptions(ctx, userDID, activeFeedURLs) | ||
| 136 | } | 129 | } |
| 137 | 130 | ||
| 138 | -func (s *Sync) batchReconcileLikes(ctx context.Context, userDID string, records []Record) error { | 131 | +func (s *Sync) syncLikes(ctx context.Context, userDID string) error { |
| 139 | - var likes []*db.Like | 132 | + records, err := s.listRecords(ctx, userDID, CollectionLike) |
| 133 | + if err != nil { | ||
| 134 | + return err | ||
| 135 | + } | ||
| 140 | 136 | ||
| 137 | + var likes []*db.Like | ||
| 138 | + activeURIs := make(map[string]bool) | ||
| 141 | for _, r := range records { | 139 | for _, r := range records { |
| 142 | var rec LikeRecord | 140 | var rec LikeRecord |
| 143 | if err := json.Unmarshal(r.Value, &rec); err != nil { | 141 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| @@ -146,6 +144,7 @@ func (s *Sync) batchReconcileLikes(ctx context.Context, userDID string, records | |||
| 146 | if rec.FeedURL == "" || rec.ArticleURL == "" { | 144 | if rec.FeedURL == "" || rec.ArticleURL == "" { |
| 147 | continue | 145 | continue |
| 148 | } | 146 | } |
| 147 | + activeURIs[r.URI] = true | ||
| 149 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | 148 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) |
| 150 | likes = append(likes, &db.Like{ | 149 | likes = append(likes, &db.Like{ |
| 151 | URI: r.URI, | 150 | URI: r.URI, |
| @@ -157,13 +156,26 @@ func (s *Sync) batchReconcileLikes(ctx context.Context, userDID string, records | |||
| 157 | }) | 156 | }) |
| 158 | } | 157 | } |
| 159 | 158 | ||
| 160 | - return s.articles.BatchCreateLikes(ctx, likes) | 159 | + if err := s.articles.BatchCreateLikes(ctx, likes); err != nil { |
| 160 | + return err | ||
| 161 | + } | ||
| 162 | + return s.articles.DeleteOrphanedLikes(ctx, userDID, activeURIs) | ||
| 161 | } | 163 | } |
| 162 | 164 | ||
| 163 | -func (s *Sync) batchReconcileAnnotations(ctx context.Context, userDID string, records []Record) error { | 165 | +func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { |
| 166 | + annRecs, err := s.listRecords(ctx, userDID, CollectionAnnotation) | ||
| 167 | + if err != nil { | ||
| 168 | + return err | ||
| 169 | + } | ||
| 170 | + marginRecs, err := s.listRecords(ctx, userDID, CollectionMarginNote) | ||
| 171 | + if err != nil { | ||
| 172 | + return err | ||
| 173 | + } | ||
| 174 | + | ||
| 164 | var annotations []*db.Annotation | 175 | var annotations []*db.Annotation |
| 176 | + activeURIs := make(map[string]bool) | ||
| 165 | 177 | ||
| 166 | - for _, r := range records { | 178 | + for _, r := range annRecs { |
| 167 | var rec AnnotationRecord | 179 | var rec AnnotationRecord |
| 168 | if err := json.Unmarshal(r.Value, &rec); err != nil { | 180 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 169 | continue | 181 | continue |
| @@ -171,6 +183,7 @@ func (s *Sync) batchReconcileAnnotations(ctx context.Context, userDID string, re | |||
| 171 | if rec.FeedURL == "" || rec.ArticleURL == "" { | 183 | if rec.FeedURL == "" || rec.ArticleURL == "" { |
| 172 | continue | 184 | continue |
| 173 | } | 185 | } |
| 186 | + activeURIs[r.URI] = true | ||
| 174 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | 187 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) |
| 175 | a := &db.Annotation{ | 188 | a := &db.Annotation{ |
| 176 | URI: r.URI, | 189 | URI: r.URI, |
| @@ -189,13 +202,7 @@ func (s *Sync) batchReconcileAnnotations(ctx context.Context, userDID string, re | |||
| 189 | annotations = append(annotations, a) | 202 | annotations = append(annotations, a) |
| 190 | } | 203 | } |
| 191 | 204 | ||
| 192 | - return s.articles.BatchCreateAnnotations(ctx, annotations) | 205 | + for _, r := range marginRecs { |
| 193 | -} | ||
| 194 | - | ||
| 195 | -func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, records []Record) error { | ||
| 196 | - var annotations []*db.Annotation | ||
| 197 | - | ||
| 198 | - for _, r := range records { | ||
| 199 | var rec MarginNoteRecord | 206 | var rec MarginNoteRecord |
| 200 | if err := json.Unmarshal(r.Value, &rec); err != nil { | 207 | if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 201 | continue | 208 | continue |
| @@ -204,6 +211,7 @@ func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, re | |||
| 204 | if articleURL == "" { | 211 | if articleURL == "" { |
| 205 | continue | 212 | continue |
| 206 | } | 213 | } |
| 214 | + activeURIs[r.URI] = true | ||
| 207 | 215 | ||
| 208 | feedURL := "" | 216 | feedURL := "" |
| 209 | if article, err := s.articles.GetArticleByURL(ctx, articleURL); err == nil { | 217 | if article, err := s.articles.GetArticleByURL(ctx, articleURL); err == nil { |
| @@ -224,41 +232,36 @@ func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, re | |||
| 224 | }) | 232 | }) |
| 225 | } | 233 | } |
| 226 | 234 | ||
| 227 | - return s.articles.BatchCreateAnnotations(ctx, annotations) | 235 | + if err := s.articles.BatchCreateAnnotations(ctx, annotations); err != nil { |
| 236 | + return err | ||
| 237 | + } | ||
| 238 | + return s.articles.DeleteOrphanedAnnotations(ctx, userDID, activeURIs) | ||
| 228 | } | 239 | } |
| 229 | 240 | ||
| 230 | func (s *Sync) syncFollows(ctx context.Context, userDID string) error { | 241 | func (s *Sync) syncFollows(ctx context.Context, userDID string) error { |
| 231 | activeFollows := make(map[string]db.Follow) | 242 | activeFollows := make(map[string]db.Follow) |
| 232 | 243 | ||
| 233 | for _, collection := range []string{CollectionBskyFollow, CollectionTangledFollow} { | 244 | for _, collection := range []string{CollectionBskyFollow, CollectionTangledFollow} { |
| 234 | - cursor := "" | 245 | + records, err := s.listRecords(ctx, userDID, collection) |
| 235 | - for { | 246 | + if err != nil { |
| 236 | - records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) | 247 | + return err |
| 237 | - if err != nil { | 248 | + } |
| 238 | - return err | ||
| 239 | - } | ||
| 240 | - | ||
| 241 | - for _, r := range records { | ||
| 242 | - var rec FollowRecord | ||
| 243 | - if err := json.Unmarshal(r.Value, &rec); err != nil { | ||
| 244 | - continue | ||
| 245 | - } | ||
| 246 | - if rec.Subject == "" { | ||
| 247 | - continue | ||
| 248 | - } | ||
| 249 | 249 | ||
| 250 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | 250 | + for _, r := range records { |
| 251 | - activeFollows[rec.Subject] = db.Follow{ | 251 | + var rec FollowRecord |
| 252 | - URI: db.NullStr(r.URI), | 252 | + if err := json.Unmarshal(r.Value, &rec); err != nil { |
| 253 | - CID: db.NullStr(r.CID), | 253 | + continue |
| 254 | - FollowedAt: db.NullTime(t), | 254 | + } |
| 255 | - } | 255 | + if rec.Subject == "" { |
| 256 | + continue | ||
| 256 | } | 257 | } |
| 257 | 258 | ||
| 258 | - if next == "" || len(records) == 0 { | 259 | + t, _ := time.Parse(time.RFC3339, rec.CreatedAt) |
| 259 | - break | 260 | + activeFollows[rec.Subject] = db.Follow{ |
| 261 | + URI: db.NullStr(r.URI), | ||
| 262 | + CID: db.NullStr(r.CID), | ||
| 263 | + FollowedAt: db.NullTime(t), | ||
| 260 | } | 264 | } |
| 261 | - cursor = next | ||
| 262 | } | 265 | } |
| 263 | } | 266 | } |
| 264 | 267 | ||
modified
internal/cluster/social.go +1 -4 | @@ -11,10 +11,7 @@ const maxFollowDepth = 3 | ||
| 11 | 11 | func chunk[T any](s []T, size int) iter.Seq[[]T] { |
| 12 | 12 | return func(yield func([]T) bool) { |
| 13 | 13 | for i := 0; i < len(s); i += size { |
| 14 | - end := i + size | |
| 15 | - if end > len(s) { | |
| 16 | - end = len(s) | |
| 17 | - } | |
| 14 | + end := min(i+size, len(s)) | |
| 18 | 15 | if !yield(s[i:end]) { |
| 19 | 16 | return |
| 20 | 17 | } |
| @@ -11,10 +11,7 @@ const maxFollowDepth = 3 | |||
| 11 | func chunk[T any](s []T, size int) iter.Seq[[]T] { | 11 | func chunk[T any](s []T, size int) iter.Seq[[]T] { |
| 12 | return func(yield func([]T) bool) { | 12 | return func(yield func([]T) bool) { |
| 13 | for i := 0; i < len(s); i += size { | 13 | for i := 0; i < len(s); i += size { |
| 14 | - end := i + size | 14 | + end := min(i+size, len(s)) |
| 15 | - if end > len(s) { | ||
| 16 | - end = len(s) | ||
| 17 | - } | ||
| 18 | if !yield(s[i:end]) { | 15 | if !yield(s[i:end]) { |
| 19 | return | 16 | return |
| 20 | } | 17 | } |
modified
internal/db/batch_test.go +133 -0 | @@ -292,3 +292,136 @@ func TestBatchCreateAnnotations_IgnoresDuplicates(t *testing.T) { | ||
| 292 | 292 | err = dbs.Articles.BatchCreateAnnotations(ctx, annotations) |
| 293 | 293 | assert.NilError(t, err) |
| 294 | 294 | } |
| 295 | + | |
| 296 | +func TestBatchReconcileSubscriptions_DeletesOrphaned(t *testing.T) { | |
| 297 | + ctx := context.Background() | |
| 298 | + dbs := setupTestDB(t) | |
| 299 | + userDID := seedSubscriptionData(t, ctx, dbs) | |
| 300 | + | |
| 301 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("A")}) | |
| 302 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml", Title: NullStr("B")}) | |
| 303 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://c.com/feed.xml", Title: NullStr("C")}) | |
| 304 | + | |
| 305 | + subs := []SubData{ | |
| 306 | + {FeedURL: "https://a.com/feed.xml", Title: "A", URI: "at://uri-a", CID: "cid-a"}, | |
| 307 | + {FeedURL: "https://b.com/feed.xml", Title: "B", URI: "at://uri-b", CID: "cid-b"}, | |
| 308 | + {FeedURL: "https://c.com/feed.xml", Title: "C", URI: "at://uri-c", CID: "cid-c"}, | |
| 309 | + } | |
| 310 | + err := dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs) | |
| 311 | + assert.NilError(t, err) | |
| 312 | + | |
| 313 | + err = dbs.Articles.DeleteOrphanedSubscriptions(ctx, userDID, map[string]bool{ | |
| 314 | + "https://a.com/feed.xml": true, | |
| 315 | + "https://c.com/feed.xml": true, | |
| 316 | + }) | |
| 317 | + assert.NilError(t, err) | |
| 318 | + | |
| 319 | + list, err := dbs.Articles.ListSubscriptions(ctx, userDID, "", 10, 0) | |
| 320 | + assert.NilError(t, err) | |
| 321 | + assert.Equal(t, len(list), 2) | |
| 322 | + | |
| 323 | + feedURLs := map[string]bool{} | |
| 324 | + for _, s := range list { | |
| 325 | + feedURLs[s.FeedURL] = true | |
| 326 | + } | |
| 327 | + assert.Assert(t, feedURLs["https://a.com/feed.xml"]) | |
| 328 | + assert.Assert(t, feedURLs["https://c.com/feed.xml"]) | |
| 329 | + | |
| 330 | + f, err := dbs.Articles.GetFeed(ctx, "https://b.com/feed.xml") | |
| 331 | + assert.NilError(t, err) | |
| 332 | + assert.Equal(t, f.SubscriberCount, 0) | |
| 333 | +} | |
| 334 | + | |
| 335 | +func TestBatchReconcileSubscriptions_PreservesLocalOnly(t *testing.T) { | |
| 336 | + ctx := context.Background() | |
| 337 | + dbs := setupTestDB(t) | |
| 338 | + userDID := seedSubscriptionData(t, ctx, dbs) | |
| 339 | + | |
| 340 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) | |
| 341 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml"}) | |
| 342 | + | |
| 343 | + err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "", "at://uri-a", "cid") | |
| 344 | + assert.NilError(t, err) | |
| 345 | + err = dbs.Articles.CreateSubscription(ctx, userDID, "https://b.com/feed.xml", "B", "", "", "") | |
| 346 | + assert.NilError(t, err) | |
| 347 | + | |
| 348 | + err = dbs.Articles.DeleteOrphanedSubscriptions(ctx, userDID, map[string]bool{ | |
| 349 | + "https://a.com/feed.xml": true, | |
| 350 | + }) | |
| 351 | + assert.NilError(t, err) | |
| 352 | + | |
| 353 | + list, err := dbs.Articles.ListSubscriptions(ctx, userDID, "", 10, 0) | |
| 354 | + assert.NilError(t, err) | |
| 355 | + assert.Equal(t, len(list), 2) | |
| 356 | +} | |
| 357 | + | |
| 358 | +func TestBatchReconcileSubscriptions_DeletesAllWhenEmpty(t *testing.T) { | |
| 359 | + ctx := context.Background() | |
| 360 | + dbs := setupTestDB(t) | |
| 361 | + userDID := seedSubscriptionData(t, ctx, dbs) | |
| 362 | + | |
| 363 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) | |
| 364 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml"}) | |
| 365 | + | |
| 366 | + subs := []SubData{ | |
| 367 | + {FeedURL: "https://a.com/feed.xml", URI: "at://uri-a", CID: "cid-a"}, | |
| 368 | + {FeedURL: "https://b.com/feed.xml", URI: "at://uri-b", CID: "cid-b"}, | |
| 369 | + } | |
| 370 | + err := dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs) | |
| 371 | + assert.NilError(t, err) | |
| 372 | + | |
| 373 | + err = dbs.Articles.DeleteOrphanedSubscriptions(ctx, userDID, map[string]bool{}) | |
| 374 | + assert.NilError(t, err) | |
| 375 | + | |
| 376 | + list, err := dbs.Articles.ListSubscriptions(ctx, userDID, "", 10, 0) | |
| 377 | + assert.NilError(t, err) | |
| 378 | + assert.Equal(t, len(list), 0) | |
| 379 | +} | |
| 380 | + | |
| 381 | +func TestDeleteOrphanedLikes_RemovesOrphaned(t *testing.T) { | |
| 382 | + ctx := context.Background() | |
| 383 | + dbs := setupTestDB(t) | |
| 384 | + | |
| 385 | + now := NullTime(time.Now()) | |
| 386 | + likes := []*Like{ | |
| 387 | + {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now, CID: NullStr("cid1")}, | |
| 388 | + {URI: "at://like2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", CreatedAt: now, CID: NullStr("cid2")}, | |
| 389 | + } | |
| 390 | + err := dbs.Articles.BatchCreateLikes(ctx, likes) | |
| 391 | + assert.NilError(t, err) | |
| 392 | + | |
| 393 | + err = dbs.Articles.DeleteOrphanedLikes(ctx, "did:test:u1", map[string]bool{"at://like1": true}) | |
| 394 | + assert.NilError(t, err) | |
| 395 | + | |
| 396 | + exists, err := dbs.Articles.HasLiked(ctx, "did:test:u1", "https://a.com/feed", "https://a.com/1") | |
| 397 | + assert.NilError(t, err) | |
| 398 | + assert.Equal(t, exists, true) | |
| 399 | + | |
| 400 | + exists, err = dbs.Articles.HasLiked(ctx, "did:test:u1", "https://a.com/feed", "https://a.com/2") | |
| 401 | + assert.NilError(t, err) | |
| 402 | + assert.Equal(t, exists, false) | |
| 403 | +} | |
| 404 | + | |
| 405 | +func TestDeleteOrphanedAnnotations_RemovesOrphaned(t *testing.T) { | |
| 406 | + ctx := context.Background() | |
| 407 | + dbs := setupTestDB(t) | |
| 408 | + | |
| 409 | + now := NullTime(time.Now()) | |
| 410 | + annotations := []*Annotation{ | |
| 411 | + {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", Note: NullStr("Keep"), CreatedAt: now}, | |
| 412 | + {URI: "at://ann2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", Note: NullStr("Remove"), CreatedAt: now}, | |
| 413 | + } | |
| 414 | + err := dbs.Articles.BatchCreateAnnotations(ctx, annotations) | |
| 415 | + assert.NilError(t, err) | |
| 416 | + | |
| 417 | + err = dbs.Articles.DeleteOrphanedAnnotations(ctx, "did:test:u1", map[string]bool{"at://ann1": true}) | |
| 418 | + assert.NilError(t, err) | |
| 419 | + | |
| 420 | + exists, err := dbs.Articles.AnnotationExists(ctx, "at://ann1") | |
| 421 | + assert.NilError(t, err) | |
| 422 | + assert.Equal(t, exists, true) | |
| 423 | + | |
| 424 | + exists, err = dbs.Articles.AnnotationExists(ctx, "at://ann2") | |
| 425 | + assert.NilError(t, err) | |
| 426 | + assert.Equal(t, exists, false) | |
| 427 | +} | |
| @@ -292,3 +292,136 @@ func TestBatchCreateAnnotations_IgnoresDuplicates(t *testing.T) { | |||
| 292 | err = dbs.Articles.BatchCreateAnnotations(ctx, annotations) | 292 | err = dbs.Articles.BatchCreateAnnotations(ctx, annotations) |
| 293 | assert.NilError(t, err) | 293 | assert.NilError(t, err) |
| 294 | } | 294 | } |
| 295 | + | ||
| 296 | +func TestBatchReconcileSubscriptions_DeletesOrphaned(t *testing.T) { | ||
| 297 | + ctx := context.Background() | ||
| 298 | + dbs := setupTestDB(t) | ||
| 299 | + userDID := seedSubscriptionData(t, ctx, dbs) | ||
| 300 | + | ||
| 301 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("A")}) | ||
| 302 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml", Title: NullStr("B")}) | ||
| 303 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://c.com/feed.xml", Title: NullStr("C")}) | ||
| 304 | + | ||
| 305 | + subs := []SubData{ | ||
| 306 | + {FeedURL: "https://a.com/feed.xml", Title: "A", URI: "at://uri-a", CID: "cid-a"}, | ||
| 307 | + {FeedURL: "https://b.com/feed.xml", Title: "B", URI: "at://uri-b", CID: "cid-b"}, | ||
| 308 | + {FeedURL: "https://c.com/feed.xml", Title: "C", URI: "at://uri-c", CID: "cid-c"}, | ||
| 309 | + } | ||
| 310 | + err := dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs) | ||
| 311 | + assert.NilError(t, err) | ||
| 312 | + | ||
| 313 | + err = dbs.Articles.DeleteOrphanedSubscriptions(ctx, userDID, map[string]bool{ | ||
| 314 | + "https://a.com/feed.xml": true, | ||
| 315 | + "https://c.com/feed.xml": true, | ||
| 316 | + }) | ||
| 317 | + assert.NilError(t, err) | ||
| 318 | + | ||
| 319 | + list, err := dbs.Articles.ListSubscriptions(ctx, userDID, "", 10, 0) | ||
| 320 | + assert.NilError(t, err) | ||
| 321 | + assert.Equal(t, len(list), 2) | ||
| 322 | + | ||
| 323 | + feedURLs := map[string]bool{} | ||
| 324 | + for _, s := range list { | ||
| 325 | + feedURLs[s.FeedURL] = true | ||
| 326 | + } | ||
| 327 | + assert.Assert(t, feedURLs["https://a.com/feed.xml"]) | ||
| 328 | + assert.Assert(t, feedURLs["https://c.com/feed.xml"]) | ||
| 329 | + | ||
| 330 | + f, err := dbs.Articles.GetFeed(ctx, "https://b.com/feed.xml") | ||
| 331 | + assert.NilError(t, err) | ||
| 332 | + assert.Equal(t, f.SubscriberCount, 0) | ||
| 333 | +} | ||
| 334 | + | ||
| 335 | +func TestBatchReconcileSubscriptions_PreservesLocalOnly(t *testing.T) { | ||
| 336 | + ctx := context.Background() | ||
| 337 | + dbs := setupTestDB(t) | ||
| 338 | + userDID := seedSubscriptionData(t, ctx, dbs) | ||
| 339 | + | ||
| 340 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) | ||
| 341 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml"}) | ||
| 342 | + | ||
| 343 | + err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "", "at://uri-a", "cid") | ||
| 344 | + assert.NilError(t, err) | ||
| 345 | + err = dbs.Articles.CreateSubscription(ctx, userDID, "https://b.com/feed.xml", "B", "", "", "") | ||
| 346 | + assert.NilError(t, err) | ||
| 347 | + | ||
| 348 | + err = dbs.Articles.DeleteOrphanedSubscriptions(ctx, userDID, map[string]bool{ | ||
| 349 | + "https://a.com/feed.xml": true, | ||
| 350 | + }) | ||
| 351 | + assert.NilError(t, err) | ||
| 352 | + | ||
| 353 | + list, err := dbs.Articles.ListSubscriptions(ctx, userDID, "", 10, 0) | ||
| 354 | + assert.NilError(t, err) | ||
| 355 | + assert.Equal(t, len(list), 2) | ||
| 356 | +} | ||
| 357 | + | ||
| 358 | +func TestBatchReconcileSubscriptions_DeletesAllWhenEmpty(t *testing.T) { | ||
| 359 | + ctx := context.Background() | ||
| 360 | + dbs := setupTestDB(t) | ||
| 361 | + userDID := seedSubscriptionData(t, ctx, dbs) | ||
| 362 | + | ||
| 363 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) | ||
| 364 | + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml"}) | ||
| 365 | + | ||
| 366 | + subs := []SubData{ | ||
| 367 | + {FeedURL: "https://a.com/feed.xml", URI: "at://uri-a", CID: "cid-a"}, | ||
| 368 | + {FeedURL: "https://b.com/feed.xml", URI: "at://uri-b", CID: "cid-b"}, | ||
| 369 | + } | ||
| 370 | + err := dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs) | ||
| 371 | + assert.NilError(t, err) | ||
| 372 | + | ||
| 373 | + err = dbs.Articles.DeleteOrphanedSubscriptions(ctx, userDID, map[string]bool{}) | ||
| 374 | + assert.NilError(t, err) | ||
| 375 | + | ||
| 376 | + list, err := dbs.Articles.ListSubscriptions(ctx, userDID, "", 10, 0) | ||
| 377 | + assert.NilError(t, err) | ||
| 378 | + assert.Equal(t, len(list), 0) | ||
| 379 | +} | ||
| 380 | + | ||
| 381 | +func TestDeleteOrphanedLikes_RemovesOrphaned(t *testing.T) { | ||
| 382 | + ctx := context.Background() | ||
| 383 | + dbs := setupTestDB(t) | ||
| 384 | + | ||
| 385 | + now := NullTime(time.Now()) | ||
| 386 | + likes := []*Like{ | ||
| 387 | + {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now, CID: NullStr("cid1")}, | ||
| 388 | + {URI: "at://like2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", CreatedAt: now, CID: NullStr("cid2")}, | ||
| 389 | + } | ||
| 390 | + err := dbs.Articles.BatchCreateLikes(ctx, likes) | ||
| 391 | + assert.NilError(t, err) | ||
| 392 | + | ||
| 393 | + err = dbs.Articles.DeleteOrphanedLikes(ctx, "did:test:u1", map[string]bool{"at://like1": true}) | ||
| 394 | + assert.NilError(t, err) | ||
| 395 | + | ||
| 396 | + exists, err := dbs.Articles.HasLiked(ctx, "did:test:u1", "https://a.com/feed", "https://a.com/1") | ||
| 397 | + assert.NilError(t, err) | ||
| 398 | + assert.Equal(t, exists, true) | ||
| 399 | + | ||
| 400 | + exists, err = dbs.Articles.HasLiked(ctx, "did:test:u1", "https://a.com/feed", "https://a.com/2") | ||
| 401 | + assert.NilError(t, err) | ||
| 402 | + assert.Equal(t, exists, false) | ||
| 403 | +} | ||
| 404 | + | ||
| 405 | +func TestDeleteOrphanedAnnotations_RemovesOrphaned(t *testing.T) { | ||
| 406 | + ctx := context.Background() | ||
| 407 | + dbs := setupTestDB(t) | ||
| 408 | + | ||
| 409 | + now := NullTime(time.Now()) | ||
| 410 | + annotations := []*Annotation{ | ||
| 411 | + {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", Note: NullStr("Keep"), CreatedAt: now}, | ||
| 412 | + {URI: "at://ann2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", Note: NullStr("Remove"), CreatedAt: now}, | ||
| 413 | + } | ||
| 414 | + err := dbs.Articles.BatchCreateAnnotations(ctx, annotations) | ||
| 415 | + assert.NilError(t, err) | ||
| 416 | + | ||
| 417 | + err = dbs.Articles.DeleteOrphanedAnnotations(ctx, "did:test:u1", map[string]bool{"at://ann1": true}) | ||
| 418 | + assert.NilError(t, err) | ||
| 419 | + | ||
| 420 | + exists, err := dbs.Articles.AnnotationExists(ctx, "at://ann1") | ||
| 421 | + assert.NilError(t, err) | ||
| 422 | + assert.Equal(t, exists, true) | ||
| 423 | + | ||
| 424 | + exists, err = dbs.Articles.AnnotationExists(ctx, "at://ann2") | ||
| 425 | + assert.NilError(t, err) | ||
| 426 | + assert.Equal(t, exists, false) | ||
| 427 | +} | ||
modified
internal/db/feed.go +60 -1 | @@ -119,10 +119,27 @@ func (s *ArticleStore) decrementSubscriberCount(ctx context.Context, feedURL str | ||
| 119 | 119 | return err |
| 120 | 120 | } |
| 121 | 121 | |
| 122 | +func (s *ArticleStore) incrementSubscriberCount(ctx context.Context, feedURL string) error { | |
| 123 | + _, err := s.db.ExecContext(ctx, ` | |
| 124 | + UPDATE articles.feeds SET subscriber_count = subscriber_count + 1 WHERE feed_url = ? | |
| 125 | + `, feedURL) | |
| 126 | + return err | |
| 127 | +} | |
| 128 | + | |
| 122 | 129 | func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error { |
| 123 | 130 | existing, err := s.GetSubscription(ctx, userDID, feedURL) |
| 124 | 131 | if err != nil || existing == nil { |
| 125 | - return s.BatchReconcileSubscriptions(ctx, userDID, []SubData{{FeedURL: feedURL, Title: title, Category: category, URI: uri, CID: cid}}) | |
| 132 | + result, err := s.db.ExecContext(ctx, ` | |
| 133 | + INSERT OR IGNORE INTO articles.subscriptions (user_did, feed_url, title, category, uri, cid) | |
| 134 | + VALUES (?, ?, ?, ?, ?, ?) | |
| 135 | + `, userDID, feedURL, nilIfEmpty(title), nilIfEmpty(category), nilIfEmpty(uri), nilIfEmpty(cid)) | |
| 136 | + if err != nil { | |
| 137 | + return err | |
| 138 | + } | |
| 139 | + if n, _ := result.RowsAffected(); n > 0 { | |
| 140 | + return s.incrementSubscriberCount(ctx, feedURL) | |
| 141 | + } | |
| 142 | + return nil | |
| 126 | 143 | } |
| 127 | 144 | |
| 128 | 145 | unchanged := existing.FeedTitle == title && existing.Category.String == category && existing.CID.String == cid |
| @@ -465,6 +482,48 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID | ||
| 465 | 482 | return tx.Commit() |
| 466 | 483 | } |
| 467 | 484 | |
| 485 | +func (s *ArticleStore) DeleteOrphanedSubscriptions(ctx context.Context, userDID string, activeFeedURLs map[string]bool) error { | |
| 486 | + rows, err := s.db.QueryContext(ctx, | |
| 487 | + `SELECT feed_url FROM articles.subscriptions WHERE user_did = ? AND uri IS NOT NULL AND uri != ''`, | |
| 488 | + userDID) | |
| 489 | + if err != nil { | |
| 490 | + return err | |
| 491 | + } | |
| 492 | + var toDelete []string | |
| 493 | + for rows.Next() { | |
| 494 | + var feedURL string | |
| 495 | + if err := rows.Scan(&feedURL); err != nil { | |
| 496 | + rows.Close() | |
| 497 | + return err | |
| 498 | + } | |
| 499 | + if !activeFeedURLs[feedURL] { | |
| 500 | + toDelete = append(toDelete, feedURL) | |
| 501 | + } | |
| 502 | + } | |
| 503 | + rows.Close() | |
| 504 | + | |
| 505 | + if len(toDelete) == 0 { | |
| 506 | + return nil | |
| 507 | + } | |
| 508 | + | |
| 509 | + tx, err := s.db.BeginTx(ctx, nil) | |
| 510 | + if err != nil { | |
| 511 | + return err | |
| 512 | + } | |
| 513 | + defer tx.Rollback() | |
| 514 | + | |
| 515 | + for _, feedURL := range toDelete { | |
| 516 | + if _, err := tx.ExecContext(ctx, `DELETE FROM articles.subscriptions WHERE user_did = ? AND feed_url = ?`, userDID, feedURL); err != nil { | |
| 517 | + return err | |
| 518 | + } | |
| 519 | + if _, err := tx.ExecContext(ctx, `UPDATE articles.feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, feedURL); err != nil { | |
| 520 | + return err | |
| 521 | + } | |
| 522 | + } | |
| 523 | + | |
| 524 | + return tx.Commit() | |
| 525 | +} | |
| 526 | + | |
| 468 | 527 | func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { |
| 469 | 528 | rows, err := s.db.QueryContext(ctx, `SELECT * FROM articles.feeds |
| 470 | 529 | WHERE feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?) |
| @@ -119,10 +119,27 @@ func (s *ArticleStore) decrementSubscriberCount(ctx context.Context, feedURL str | |||
| 119 | return err | 119 | return err |
| 120 | } | 120 | } |
| 121 | 121 | ||
| 122 | +func (s *ArticleStore) incrementSubscriberCount(ctx context.Context, feedURL string) error { | ||
| 123 | + _, err := s.db.ExecContext(ctx, ` | ||
| 124 | + UPDATE articles.feeds SET subscriber_count = subscriber_count + 1 WHERE feed_url = ? | ||
| 125 | + `, feedURL) | ||
| 126 | + return err | ||
| 127 | +} | ||
| 128 | + | ||
| 122 | func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error { | 129 | func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error { |
| 123 | existing, err := s.GetSubscription(ctx, userDID, feedURL) | 130 | existing, err := s.GetSubscription(ctx, userDID, feedURL) |
| 124 | if err != nil || existing == nil { | 131 | if err != nil || existing == nil { |
| 125 | - return s.BatchReconcileSubscriptions(ctx, userDID, []SubData{{FeedURL: feedURL, Title: title, Category: category, URI: uri, CID: cid}}) | 132 | + result, err := s.db.ExecContext(ctx, ` |
| 133 | + INSERT OR IGNORE INTO articles.subscriptions (user_did, feed_url, title, category, uri, cid) | ||
| 134 | + VALUES (?, ?, ?, ?, ?, ?) | ||
| 135 | + `, userDID, feedURL, nilIfEmpty(title), nilIfEmpty(category), nilIfEmpty(uri), nilIfEmpty(cid)) | ||
| 136 | + if err != nil { | ||
| 137 | + return err | ||
| 138 | + } | ||
| 139 | + if n, _ := result.RowsAffected(); n > 0 { | ||
| 140 | + return s.incrementSubscriberCount(ctx, feedURL) | ||
| 141 | + } | ||
| 142 | + return nil | ||
| 126 | } | 143 | } |
| 127 | 144 | ||
| 128 | unchanged := existing.FeedTitle == title && existing.Category.String == category && existing.CID.String == cid | 145 | unchanged := existing.FeedTitle == title && existing.Category.String == category && existing.CID.String == cid |
| @@ -465,6 +482,48 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID | |||
| 465 | return tx.Commit() | 482 | return tx.Commit() |
| 466 | } | 483 | } |
| 467 | 484 | ||
| 485 | +func (s *ArticleStore) DeleteOrphanedSubscriptions(ctx context.Context, userDID string, activeFeedURLs map[string]bool) error { | ||
| 486 | + rows, err := s.db.QueryContext(ctx, | ||
| 487 | + `SELECT feed_url FROM articles.subscriptions WHERE user_did = ? AND uri IS NOT NULL AND uri != ''`, | ||
| 488 | + userDID) | ||
| 489 | + if err != nil { | ||
| 490 | + return err | ||
| 491 | + } | ||
| 492 | + var toDelete []string | ||
| 493 | + for rows.Next() { | ||
| 494 | + var feedURL string | ||
| 495 | + if err := rows.Scan(&feedURL); err != nil { | ||
| 496 | + rows.Close() | ||
| 497 | + return err | ||
| 498 | + } | ||
| 499 | + if !activeFeedURLs[feedURL] { | ||
| 500 | + toDelete = append(toDelete, feedURL) | ||
| 501 | + } | ||
| 502 | + } | ||
| 503 | + rows.Close() | ||
| 504 | + | ||
| 505 | + if len(toDelete) == 0 { | ||
| 506 | + return nil | ||
| 507 | + } | ||
| 508 | + | ||
| 509 | + tx, err := s.db.BeginTx(ctx, nil) | ||
| 510 | + if err != nil { | ||
| 511 | + return err | ||
| 512 | + } | ||
| 513 | + defer tx.Rollback() | ||
| 514 | + | ||
| 515 | + for _, feedURL := range toDelete { | ||
| 516 | + if _, err := tx.ExecContext(ctx, `DELETE FROM articles.subscriptions WHERE user_did = ? AND feed_url = ?`, userDID, feedURL); err != nil { | ||
| 517 | + return err | ||
| 518 | + } | ||
| 519 | + if _, err := tx.ExecContext(ctx, `UPDATE articles.feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, feedURL); err != nil { | ||
| 520 | + return err | ||
| 521 | + } | ||
| 522 | + } | ||
| 523 | + | ||
| 524 | + return tx.Commit() | ||
| 525 | +} | ||
| 526 | + | ||
| 468 | func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { | 527 | func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { |
| 469 | rows, err := s.db.QueryContext(ctx, `SELECT * FROM articles.feeds | 528 | rows, err := s.db.QueryContext(ctx, `SELECT * FROM articles.feeds |
| 470 | WHERE feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?) | 529 | WHERE feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?) |
modified
internal/db/social.go +84 -2 | @@ -36,7 +36,11 @@ type Like struct { | ||
| 36 | 36 | } |
| 37 | 37 | |
| 38 | 38 | func (s *ArticleStore) CreateAnnotation(ctx context.Context, a *Annotation) error { |
| 39 | - return s.BatchCreateAnnotations(ctx, []*Annotation{a}) | |
| 39 | + _, err := s.db.ExecContext(ctx, ` | |
| 40 | + INSERT OR IGNORE INTO articles.annotations (uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid) | |
| 41 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 42 | + `, a.URI, a.AuthorDID, a.FeedURL, a.ArticleURL, a.Quote, a.Note, a.Tags, a.Rating, a.CreatedAt, a.CID) | |
| 43 | + return err | |
| 40 | 44 | } |
| 41 | 45 | |
| 42 | 46 | func (s *ArticleStore) UpdateAnnotation(ctx context.Context, a *Annotation) error { |
| @@ -185,7 +189,85 @@ func (s *ArticleStore) CreateLike(ctx context.Context, l *Like) error { | ||
| 185 | 189 | if exists { |
| 186 | 190 | return ErrDuplicateLike |
| 187 | 191 | } |
| 188 | - return s.BatchCreateLikes(ctx, []*Like{l}) | |
| 192 | + _, err = s.db.ExecContext(ctx, ` | |
| 193 | + INSERT OR IGNORE INTO articles.likes (uri, author_did, feed_url, article_url, created_at, cid) | |
| 194 | + VALUES (?, ?, ?, ?, ?, ?) | |
| 195 | + `, l.URI, l.AuthorDID, l.FeedURL, l.ArticleURL, l.CreatedAt, l.CID) | |
| 196 | + return err | |
| 197 | +} | |
| 198 | + | |
| 199 | +func (s *ArticleStore) DeleteOrphanedLikes(ctx context.Context, userDID string, activeURIs map[string]bool) error { | |
| 200 | + rows, err := s.db.QueryContext(ctx, | |
| 201 | + `SELECT uri FROM articles.likes WHERE author_did = ? AND uri IS NOT NULL AND uri != ''`, | |
| 202 | + userDID) | |
| 203 | + if err != nil { | |
| 204 | + return err | |
| 205 | + } | |
| 206 | + var toDelete []string | |
| 207 | + for rows.Next() { | |
| 208 | + var uri string | |
| 209 | + if err := rows.Scan(&uri); err != nil { | |
| 210 | + rows.Close() | |
| 211 | + return err | |
| 212 | + } | |
| 213 | + if !activeURIs[uri] { | |
| 214 | + toDelete = append(toDelete, uri) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + rows.Close() | |
| 218 | + | |
| 219 | + if len(toDelete) == 0 { | |
| 220 | + return nil | |
| 221 | + } | |
| 222 | + | |
| 223 | + ph := make([]string, len(toDelete)) | |
| 224 | + args := make([]any, 0, len(toDelete)+1) | |
| 225 | + args = append(args, userDID) | |
| 226 | + for i, uri := range toDelete { | |
| 227 | + ph[i] = "?" | |
| 228 | + args = append(args, uri) | |
| 229 | + } | |
| 230 | + _, err = s.db.ExecContext(ctx, | |
| 231 | + `DELETE FROM articles.likes WHERE author_did = ? AND uri IN (`+strings.Join(ph, ",")+`)`, | |
| 232 | + args...) | |
| 233 | + return err | |
| 234 | +} | |
| 235 | + | |
| 236 | +func (s *ArticleStore) DeleteOrphanedAnnotations(ctx context.Context, userDID string, activeURIs map[string]bool) error { | |
| 237 | + rows, err := s.db.QueryContext(ctx, | |
| 238 | + `SELECT uri FROM articles.annotations WHERE author_did = ? AND uri IS NOT NULL AND uri != ''`, | |
| 239 | + userDID) | |
| 240 | + if err != nil { | |
| 241 | + return err | |
| 242 | + } | |
| 243 | + var toDelete []string | |
| 244 | + for rows.Next() { | |
| 245 | + var uri string | |
| 246 | + if err := rows.Scan(&uri); err != nil { | |
| 247 | + rows.Close() | |
| 248 | + return err | |
| 249 | + } | |
| 250 | + if !activeURIs[uri] { | |
| 251 | + toDelete = append(toDelete, uri) | |
| 252 | + } | |
| 253 | + } | |
| 254 | + rows.Close() | |
| 255 | + | |
| 256 | + if len(toDelete) == 0 { | |
| 257 | + return nil | |
| 258 | + } | |
| 259 | + | |
| 260 | + ph := make([]string, len(toDelete)) | |
| 261 | + args := make([]any, 0, len(toDelete)+1) | |
| 262 | + args = append(args, userDID) | |
| 263 | + for i, uri := range toDelete { | |
| 264 | + ph[i] = "?" | |
| 265 | + args = append(args, uri) | |
| 266 | + } | |
| 267 | + _, err = s.db.ExecContext(ctx, | |
| 268 | + `DELETE FROM articles.annotations WHERE author_did = ? AND uri IN (`+strings.Join(ph, ",")+`)`, | |
| 269 | + args...) | |
| 270 | + return err | |
| 189 | 271 | } |
| 190 | 272 | |
| 191 | 273 | func (s *ArticleStore) DeleteLike(ctx context.Context, uri string) error { |
| @@ -36,7 +36,11 @@ type Like struct { | |||
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | func (s *ArticleStore) CreateAnnotation(ctx context.Context, a *Annotation) error { | 38 | func (s *ArticleStore) CreateAnnotation(ctx context.Context, a *Annotation) error { |
| 39 | - return s.BatchCreateAnnotations(ctx, []*Annotation{a}) | 39 | + _, err := s.db.ExecContext(ctx, ` |
| 40 | + INSERT OR IGNORE INTO articles.annotations (uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid) | ||
| 41 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| 42 | + `, a.URI, a.AuthorDID, a.FeedURL, a.ArticleURL, a.Quote, a.Note, a.Tags, a.Rating, a.CreatedAt, a.CID) | ||
| 43 | + return err | ||
| 40 | } | 44 | } |
| 41 | 45 | ||
| 42 | func (s *ArticleStore) UpdateAnnotation(ctx context.Context, a *Annotation) error { | 46 | func (s *ArticleStore) UpdateAnnotation(ctx context.Context, a *Annotation) error { |
| @@ -185,7 +189,85 @@ func (s *ArticleStore) CreateLike(ctx context.Context, l *Like) error { | |||
| 185 | if exists { | 189 | if exists { |
| 186 | return ErrDuplicateLike | 190 | return ErrDuplicateLike |
| 187 | } | 191 | } |
| 188 | - return s.BatchCreateLikes(ctx, []*Like{l}) | 192 | + _, err = s.db.ExecContext(ctx, ` |
| 193 | + INSERT OR IGNORE INTO articles.likes (uri, author_did, feed_url, article_url, created_at, cid) | ||
| 194 | + VALUES (?, ?, ?, ?, ?, ?) | ||
| 195 | + `, l.URI, l.AuthorDID, l.FeedURL, l.ArticleURL, l.CreatedAt, l.CID) | ||
| 196 | + return err | ||
| 197 | +} | ||
| 198 | + | ||
| 199 | +func (s *ArticleStore) DeleteOrphanedLikes(ctx context.Context, userDID string, activeURIs map[string]bool) error { | ||
| 200 | + rows, err := s.db.QueryContext(ctx, | ||
| 201 | + `SELECT uri FROM articles.likes WHERE author_did = ? AND uri IS NOT NULL AND uri != ''`, | ||
| 202 | + userDID) | ||
| 203 | + if err != nil { | ||
| 204 | + return err | ||
| 205 | + } | ||
| 206 | + var toDelete []string | ||
| 207 | + for rows.Next() { | ||
| 208 | + var uri string | ||
| 209 | + if err := rows.Scan(&uri); err != nil { | ||
| 210 | + rows.Close() | ||
| 211 | + return err | ||
| 212 | + } | ||
| 213 | + if !activeURIs[uri] { | ||
| 214 | + toDelete = append(toDelete, uri) | ||
| 215 | + } | ||
| 216 | + } | ||
| 217 | + rows.Close() | ||
| 218 | + | ||
| 219 | + if len(toDelete) == 0 { | ||
| 220 | + return nil | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + ph := make([]string, len(toDelete)) | ||
| 224 | + args := make([]any, 0, len(toDelete)+1) | ||
| 225 | + args = append(args, userDID) | ||
| 226 | + for i, uri := range toDelete { | ||
| 227 | + ph[i] = "?" | ||
| 228 | + args = append(args, uri) | ||
| 229 | + } | ||
| 230 | + _, err = s.db.ExecContext(ctx, | ||
| 231 | + `DELETE FROM articles.likes WHERE author_did = ? AND uri IN (`+strings.Join(ph, ",")+`)`, | ||
| 232 | + args...) | ||
| 233 | + return err | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +func (s *ArticleStore) DeleteOrphanedAnnotations(ctx context.Context, userDID string, activeURIs map[string]bool) error { | ||
| 237 | + rows, err := s.db.QueryContext(ctx, | ||
| 238 | + `SELECT uri FROM articles.annotations WHERE author_did = ? AND uri IS NOT NULL AND uri != ''`, | ||
| 239 | + userDID) | ||
| 240 | + if err != nil { | ||
| 241 | + return err | ||
| 242 | + } | ||
| 243 | + var toDelete []string | ||
| 244 | + for rows.Next() { | ||
| 245 | + var uri string | ||
| 246 | + if err := rows.Scan(&uri); err != nil { | ||
| 247 | + rows.Close() | ||
| 248 | + return err | ||
| 249 | + } | ||
| 250 | + if !activeURIs[uri] { | ||
| 251 | + toDelete = append(toDelete, uri) | ||
| 252 | + } | ||
| 253 | + } | ||
| 254 | + rows.Close() | ||
| 255 | + | ||
| 256 | + if len(toDelete) == 0 { | ||
| 257 | + return nil | ||
| 258 | + } | ||
| 259 | + | ||
| 260 | + ph := make([]string, len(toDelete)) | ||
| 261 | + args := make([]any, 0, len(toDelete)+1) | ||
| 262 | + args = append(args, userDID) | ||
| 263 | + for i, uri := range toDelete { | ||
| 264 | + ph[i] = "?" | ||
| 265 | + args = append(args, uri) | ||
| 266 | + } | ||
| 267 | + _, err = s.db.ExecContext(ctx, | ||
| 268 | + `DELETE FROM articles.annotations WHERE author_did = ? AND uri IN (`+strings.Join(ph, ",")+`)`, | ||
| 269 | + args...) | ||
| 270 | + return err | ||
| 189 | } | 271 | } |
| 190 | 272 | ||
| 191 | func (s *ArticleStore) DeleteLike(ctx context.Context, uri string) error { | 273 | func (s *ArticleStore) DeleteLike(ctx context.Context, uri string) error { |