nandi/gleanpublic⑂ Fork 0
⑂ 947a4c9
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Implement orphan cleanup for sync operationsUnverified

Julien Robert committed 2026-05-01T00:34:11+02:00 Browse files
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
202202 return h.articles.UpdateAnnotation(ctx, a)
203203
204204 case actionDelete:
205+ // Margin notes are converted to annotations; their URI is tracked
206+ // by sync so orphan cleanup handles deletion during backfill.
205207 }
206208 return nil
207209 }
@@ -252,6 +254,8 @@ func (h *StreamDBHandler) handleSkyreaderSubscription(ctx context.Context, event
252254 return err
253255
254256 case actionDelete:
257+ // Skyreader subscriptions are imported into glean subscriptions;
258+ // sync's orphan cleanup handles deletion during backfill.
255259 }
256260 return nil
257261 }
@@ -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 nil208 return nil
207 }209 }
@@ -252,6 +254,8 @@ func (h *StreamDBHandler) handleSkyreaderSubscription(ctx context.Context, event
252 return err254 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 nil260 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
3333 func (s *Sync) Run(ctx context.Context, userDID string) error {
3434 s.logger.Info("syncing from PDS", "did", userDID)
3535
36- if err := s.syncCollection(ctx, userDID, CollectionSubscription, s.batchReconcileSubscriptions); err != nil {
36+ if err := s.syncSubscriptions(ctx, userDID); err != nil {
3737 s.logger.Error("sync subscriptions failed", "error", err, "did", userDID)
3838 }
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 {
4340 s.logger.Error("sync likes failed", "error", err, "did", userDID)
4441 }
45- if err := s.syncCollection(ctx, userDID, CollectionAnnotation, s.batchReconcileAnnotations); err != nil {
42+ if err := s.syncAnnotations(ctx, userDID); err != nil {
4643 s.logger.Error("sync annotations failed", "error", err, "did", userDID)
4744 }
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- }
5145 if err := s.syncFollows(ctx, userDID); err != nil {
5246 s.logger.Error("sync follows failed", "error", err, "did", userDID)
5347 }
@@ -55,13 +49,13 @@ func (s *Sync) Run(ctx context.Context, userDID string) error {
5549 return nil
5650 }
5751
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) {
5953 var allRecords []Record
6054 cursor := ""
6155 for {
6256 records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor)
6357 if err != nil {
64- return err
58+ return nil, err
6559 }
6660 allRecords = append(allRecords, records...)
6761 if next == "" || len(records) == 0 {
@@ -69,17 +63,24 @@ func (s *Sync) syncCollection(ctx context.Context, userDID, collection string, f
6963 }
7064 cursor = next
7165 }
72- if len(allRecords) == 0 {
73- return nil
74- }
75- return fn(ctx, userDID, allRecords)
66+ return allRecords, nil
7667 }
7768
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+
7979 var feeds []*db.Feed
8080 var subs []db.SubData
81+ activeFeedURLs := make(map[string]bool)
8182
82- for _, r := range records {
83+ for _, r := range gleanRecs {
8384 var rec SubscriptionRecord
8485 if err := json.Unmarshal(r.Value, &rec); err != nil {
8586 continue
@@ -87,6 +88,7 @@ func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string,
8788 if rec.FeedURL == "" {
8889 continue
8990 }
91+ activeFeedURLs[rec.FeedURL] = true
9092 feeds = append(feeds, &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)})
9193 subs = append(subs, db.SubData{
9294 FeedURL: rec.FeedURL,
@@ -97,19 +99,7 @@ func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string,
9799 })
98100 }
99101
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 {
113103 var rec SkyreaderSubscriptionRecord
114104 if err := json.Unmarshal(r.Value, &rec); err != nil {
115105 continue
@@ -117,6 +107,7 @@ func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID
117107 if rec.FeedURL == "" {
118108 continue
119109 }
110+ activeFeedURLs[rec.FeedURL] = true
120111 feeds = append(feeds, &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title), SiteURL: db.NullStr(rec.SiteURL)})
121112 subs = append(subs, db.SubData{
122113 FeedURL: rec.FeedURL,
@@ -128,16 +119,23 @@ func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID
128119
129120 if len(feeds) > 0 {
130121 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)
132123 }
133124 }
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)
136129 }
137130
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+ }
140136
137+ var likes []*db.Like
138+ activeURIs := make(map[string]bool)
141139 for _, r := range records {
142140 var rec LikeRecord
143141 if err := json.Unmarshal(r.Value, &rec); err != nil {
@@ -146,6 +144,7 @@ func (s *Sync) batchReconcileLikes(ctx context.Context, userDID string, records
146144 if rec.FeedURL == "" || rec.ArticleURL == "" {
147145 continue
148146 }
147+ activeURIs[r.URI] = true
149148 t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
150149 likes = append(likes, &db.Like{
151150 URI: r.URI,
@@ -157,13 +156,26 @@ func (s *Sync) batchReconcileLikes(ctx context.Context, userDID string, records
157156 })
158157 }
159158
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)
161163 }
162164
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+
164175 var annotations []*db.Annotation
176+ activeURIs := make(map[string]bool)
165177
166- for _, r := range records {
178+ for _, r := range annRecs {
167179 var rec AnnotationRecord
168180 if err := json.Unmarshal(r.Value, &rec); err != nil {
169181 continue
@@ -171,6 +183,7 @@ func (s *Sync) batchReconcileAnnotations(ctx context.Context, userDID string, re
171183 if rec.FeedURL == "" || rec.ArticleURL == "" {
172184 continue
173185 }
186+ activeURIs[r.URI] = true
174187 t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
175188 a := &db.Annotation{
176189 URI: r.URI,
@@ -189,13 +202,7 @@ func (s *Sync) batchReconcileAnnotations(ctx context.Context, userDID string, re
189202 annotations = append(annotations, a)
190203 }
191204
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 {
199206 var rec MarginNoteRecord
200207 if err := json.Unmarshal(r.Value, &rec); err != nil {
201208 continue
@@ -204,6 +211,7 @@ func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, re
204211 if articleURL == "" {
205212 continue
206213 }
214+ activeURIs[r.URI] = true
207215
208216 feedURL := ""
209217 if article, err := s.articles.GetArticleByURL(ctx, articleURL); err == nil {
@@ -224,41 +232,36 @@ func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, re
224232 })
225233 }
226234
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)
228239 }
229240
230241 func (s *Sync) syncFollows(ctx context.Context, userDID string) error {
231242 activeFollows := make(map[string]db.Follow)
232243
233244 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+ }
249249
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
256257 }
257258
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),
260264 }
261- cursor = next
262265 }
263266 }
264267
@@ -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 nil49 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 []Record53 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 err58+ 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 = next64 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.Feed79 var feeds []*db.Feed
80 var subs []db.SubData80 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 SubscriptionRecord84 var rec SubscriptionRecord
84 if err := json.Unmarshal(r.Value, &rec); err != nil {85 if err := json.Unmarshal(r.Value, &rec); err != nil {
85 continue86 continue
@@ -87,6 +88,7 @@ func (s *Sync) batchReconcileSubscriptions(ctx context.Context, userDID string,
87 if rec.FeedURL == "" {88 if rec.FeedURL == "" {
88 continue89 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 SkyreaderSubscriptionRecord103 var rec SkyreaderSubscriptionRecord
114 if err := json.Unmarshal(r.Value, &rec); err != nil {104 if err := json.Unmarshal(r.Value, &rec); err != nil {
115 continue105 continue
@@ -117,6 +107,7 @@ func (s *Sync) batchReconcileSkyreaderSubscriptions(ctx context.Context, userDID
117 if rec.FeedURL == "" {107 if rec.FeedURL == "" {
118 continue108 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.Like132+ 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 LikeRecord140 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 continue145 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.Annotation175 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 AnnotationRecord179 var rec AnnotationRecord
168 if err := json.Unmarshal(r.Value, &rec); err != nil {180 if err := json.Unmarshal(r.Value, &rec); err != nil {
169 continue181 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 continue184 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 MarginNoteRecord206 var rec MarginNoteRecord
200 if err := json.Unmarshal(r.Value, &rec); err != nil {207 if err := json.Unmarshal(r.Value, &rec); err != nil {
201 continue208 continue
@@ -204,6 +211,7 @@ func (s *Sync) batchReconcileMarginNotes(ctx context.Context, userDID string, re
204 if articleURL == "" {211 if articleURL == "" {
205 continue212 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- break260+ 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
1111 func chunk[T any](s []T, size int) iter.Seq[[]T] {
1212 return func(yield func([]T) bool) {
1313 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))
1815 if !yield(s[i:end]) {
1916 return
2017 }
@@ -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 + size14+ 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 return16 return
20 }17 }
modified internal/db/batch_test.go +133 -0
@@ -292,3 +292,136 @@ func TestBatchCreateAnnotations_IgnoresDuplicates(t *testing.T) {
292292 err = dbs.Articles.BatchCreateAnnotations(ctx, annotations)
293293 assert.NilError(t, err)
294294 }
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
119119 return err
120120 }
121121
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+
122129 func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error {
123130 existing, err := s.GetSubscription(ctx, userDID, feedURL)
124131 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
126143 }
127144
128145 unchanged := existing.FeedTitle == title && existing.Category.String == category && existing.CID.String == cid
@@ -465,6 +482,48 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
465482 return tx.Commit()
466483 }
467484
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+
468527 func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {
469528 rows, err := s.db.QueryContext(ctx, `SELECT * FROM articles.feeds
470529 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 err119 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 == cid145 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.feeds528 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 {
3636 }
3737
3838 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
4044 }
4145
4246 func (s *ArticleStore) UpdateAnnotation(ctx context.Context, a *Annotation) error {
@@ -185,7 +189,85 @@ func (s *ArticleStore) CreateLike(ctx context.Context, l *Like) error {
185189 if exists {
186190 return ErrDuplicateLike
187191 }
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
189271 }
190272
191273 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 ErrDuplicateLike190 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 {