nandi/gleanpublic⑂ Fork 0
⑂ 0548274
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.

Allow re-updates for existing subscriptionsUnverified

Julien Robert committed 2026-04-26T22:34:35+02:00 Browse files
0548274 parent: 12453ff
modified internal/atproto/stream_handler.go +71 -42
@@ -121,30 +121,18 @@ func (h *StreamDBHandler) handleLike(ctx context.Context, event *Event) error {
121121 func (h *StreamDBHandler) handleAnnotation(ctx context.Context, event *Event) error {
122122 switch event.Type {
123123 case actionCreate:
124- var rec AnnotationRecord
125- if err := json.Unmarshal(event.Value, &rec); err != nil {
124+ a, err := parseAnnotationRecord(event)
125+ if err != nil || a == nil {
126126 return err
127127 }
128- if rec.FeedURL == "" || rec.ArticleURL == "" {
129- return nil
130- }
128+ return h.articles.CreateAnnotation(ctx, a)
131129
132- t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
133- a := &db.Annotation{
134- URI: event.URI,
135- AuthorDID: event.DID,
136- FeedURL: rec.FeedURL,
137- ArticleURL: rec.ArticleURL,
138- Quote: db.NullStr(rec.Quote),
139- Note: db.NullStr(rec.Note),
140- Tags: db.NullStrTags(rec.Tags),
141- CreatedAt: sql.NullTime{Time: t, Valid: true},
142- CID: sql.NullString{String: event.CID, Valid: event.CID != ""},
143- }
144- if rec.Rating > 0 {
145- a.Rating = sql.NullInt64{Int64: int64(rec.Rating), Valid: true}
130+ case actionUpdate:
131+ a, err := parseAnnotationRecord(event)
132+ if err != nil || a == nil {
133+ return err
146134 }
147- return h.articles.CreateAnnotation(ctx, a)
135+ return h.articles.UpdateAnnotation(ctx, a)
148136
149137 case actionDelete:
150138 return h.articles.DeleteAnnotation(ctx, event.URI)
@@ -152,6 +140,33 @@ func (h *StreamDBHandler) handleAnnotation(ctx context.Context, event *Event) er
152140 return nil
153141 }
154142
143+func parseAnnotationRecord(event *Event) (*db.Annotation, error) {
144+ var rec AnnotationRecord
145+ if err := json.Unmarshal(event.Value, &rec); err != nil {
146+ return nil, err
147+ }
148+ if rec.FeedURL == "" || rec.ArticleURL == "" {
149+ return nil, nil
150+ }
151+
152+ t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
153+ a := &db.Annotation{
154+ URI: event.URI,
155+ AuthorDID: event.DID,
156+ FeedURL: rec.FeedURL,
157+ ArticleURL: rec.ArticleURL,
158+ Quote: db.NullStr(rec.Quote),
159+ Note: db.NullStr(rec.Note),
160+ Tags: db.NullStrTags(rec.Tags),
161+ CreatedAt: sql.NullTime{Time: t, Valid: true},
162+ CID: sql.NullString{String: event.CID, Valid: event.CID != ""},
163+ }
164+ if rec.Rating > 0 {
165+ a.Rating = sql.NullInt64{Int64: int64(rec.Rating), Valid: true}
166+ }
167+ return a, nil
168+}
169+
155170 func (h *StreamDBHandler) handleFollow(ctx context.Context, event *Event) error {
156171 switch event.Type {
157172 case actionCreate:
@@ -172,38 +187,52 @@ func (h *StreamDBHandler) handleFollow(ctx context.Context, event *Event) error
172187
173188 func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) error {
174189 switch event.Type {
175- case actionCreate, actionUpdate:
176- var rec MarginNoteRecord
177- if err := json.Unmarshal(event.Value, &rec); err != nil {
190+ case actionCreate:
191+ a, err := h.parseMarginNoteRecord(ctx, event)
192+ if err != nil || a == nil {
178193 return err
179194 }
195+ return h.articles.CreateAnnotation(ctx, a)
180196
181- articleURL, quote, note, tags := rec.ToAnnotation()
182- if articleURL == "" {
183- return nil
184- }
185-
186- feedURL := h.resolveFeedURL(ctx, articleURL)
187-
188- t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
189- a := &db.Annotation{
190- URI: event.URI,
191- AuthorDID: event.DID,
192- FeedURL: feedURL,
193- ArticleURL: articleURL,
194- Quote: db.NullStr(quote),
195- Note: db.NullStr(note),
196- Tags: db.NullStrTags(tags),
197- CreatedAt: sql.NullTime{Time: t, Valid: true},
198- CID: sql.NullString{String: event.CID, Valid: event.CID != ""},
197+ case actionUpdate:
198+ a, err := h.parseMarginNoteRecord(ctx, event)
199+ if err != nil || a == nil {
200+ return err
199201 }
200- return h.articles.CreateAnnotation(ctx, a)
202+ return h.articles.UpdateAnnotation(ctx, a)
201203
202204 case actionDelete:
203205 }
204206 return nil
205207 }
206208
209+func (h *StreamDBHandler) parseMarginNoteRecord(ctx context.Context, event *Event) (*db.Annotation, error) {
210+ var rec MarginNoteRecord
211+ if err := json.Unmarshal(event.Value, &rec); err != nil {
212+ return nil, err
213+ }
214+
215+ articleURL, quote, note, tags := rec.ToAnnotation()
216+ if articleURL == "" {
217+ return nil, nil
218+ }
219+
220+ feedURL := h.resolveFeedURL(ctx, articleURL)
221+
222+ t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
223+ return &db.Annotation{
224+ URI: event.URI,
225+ AuthorDID: event.DID,
226+ FeedURL: feedURL,
227+ ArticleURL: articleURL,
228+ Quote: db.NullStr(quote),
229+ Note: db.NullStr(note),
230+ Tags: db.NullStrTags(tags),
231+ CreatedAt: sql.NullTime{Time: t, Valid: true},
232+ CID: sql.NullString{String: event.CID, Valid: event.CID != ""},
233+ }, nil
234+}
235+
207236 func (h *StreamDBHandler) handleSkyreaderSubscription(ctx context.Context, event *Event) error {
208237 switch event.Type {
209238 case actionCreate, actionUpdate:
@@ -121,30 +121,18 @@ func (h *StreamDBHandler) handleLike(ctx context.Context, event *Event) error {
121 func (h *StreamDBHandler) handleAnnotation(ctx context.Context, event *Event) error {121 func (h *StreamDBHandler) handleAnnotation(ctx context.Context, event *Event) error {
122 switch event.Type {122 switch event.Type {
123 case actionCreate:123 case actionCreate:
124- var rec AnnotationRecord124+ a, err := parseAnnotationRecord(event)
125- if err := json.Unmarshal(event.Value, &rec); err != nil {125+ if err != nil || a == nil {
126 return err126 return err
127 }127 }
128- if rec.FeedURL == "" || rec.ArticleURL == "" {128+ return h.articles.CreateAnnotation(ctx, a)
129- return nil
130- }
131 129
132- t, _ := time.Parse(time.RFC3339, rec.CreatedAt)130+ case actionUpdate:
133- a := &db.Annotation{131+ a, err := parseAnnotationRecord(event)
134- URI: event.URI,132+ if err != nil || a == nil {
135- AuthorDID: event.DID,133+ return err
136- FeedURL: rec.FeedURL,
137- ArticleURL: rec.ArticleURL,
138- Quote: db.NullStr(rec.Quote),
139- Note: db.NullStr(rec.Note),
140- Tags: db.NullStrTags(rec.Tags),
141- CreatedAt: sql.NullTime{Time: t, Valid: true},
142- CID: sql.NullString{String: event.CID, Valid: event.CID != ""},
143- }
144- if rec.Rating > 0 {
145- a.Rating = sql.NullInt64{Int64: int64(rec.Rating), Valid: true}
146 }134 }
147- return h.articles.CreateAnnotation(ctx, a)135+ return h.articles.UpdateAnnotation(ctx, a)
148 136
149 case actionDelete:137 case actionDelete:
150 return h.articles.DeleteAnnotation(ctx, event.URI)138 return h.articles.DeleteAnnotation(ctx, event.URI)
@@ -152,6 +140,33 @@ func (h *StreamDBHandler) handleAnnotation(ctx context.Context, event *Event) er
152 return nil140 return nil
153 }141 }
154 142
143+func parseAnnotationRecord(event *Event) (*db.Annotation, error) {
144+ var rec AnnotationRecord
145+ if err := json.Unmarshal(event.Value, &rec); err != nil {
146+ return nil, err
147+ }
148+ if rec.FeedURL == "" || rec.ArticleURL == "" {
149+ return nil, nil
150+ }
151+
152+ t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
153+ a := &db.Annotation{
154+ URI: event.URI,
155+ AuthorDID: event.DID,
156+ FeedURL: rec.FeedURL,
157+ ArticleURL: rec.ArticleURL,
158+ Quote: db.NullStr(rec.Quote),
159+ Note: db.NullStr(rec.Note),
160+ Tags: db.NullStrTags(rec.Tags),
161+ CreatedAt: sql.NullTime{Time: t, Valid: true},
162+ CID: sql.NullString{String: event.CID, Valid: event.CID != ""},
163+ }
164+ if rec.Rating > 0 {
165+ a.Rating = sql.NullInt64{Int64: int64(rec.Rating), Valid: true}
166+ }
167+ return a, nil
168+}
169+
155 func (h *StreamDBHandler) handleFollow(ctx context.Context, event *Event) error {170 func (h *StreamDBHandler) handleFollow(ctx context.Context, event *Event) error {
156 switch event.Type {171 switch event.Type {
157 case actionCreate:172 case actionCreate:
@@ -172,38 +187,52 @@ func (h *StreamDBHandler) handleFollow(ctx context.Context, event *Event) error
172 187
173 func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) error {188 func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) error {
174 switch event.Type {189 switch event.Type {
175- case actionCreate, actionUpdate:190+ case actionCreate:
176- var rec MarginNoteRecord191+ a, err := h.parseMarginNoteRecord(ctx, event)
177- if err := json.Unmarshal(event.Value, &rec); err != nil {192+ if err != nil || a == nil {
178 return err193 return err
179 }194 }
195+ return h.articles.CreateAnnotation(ctx, a)
180 196
181- articleURL, quote, note, tags := rec.ToAnnotation()197+ case actionUpdate:
182- if articleURL == "" {198+ a, err := h.parseMarginNoteRecord(ctx, event)
183- return nil199+ if err != nil || a == nil {
184- }200+ return err
185-
186- feedURL := h.resolveFeedURL(ctx, articleURL)
187-
188- t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
189- a := &db.Annotation{
190- URI: event.URI,
191- AuthorDID: event.DID,
192- FeedURL: feedURL,
193- ArticleURL: articleURL,
194- Quote: db.NullStr(quote),
195- Note: db.NullStr(note),
196- Tags: db.NullStrTags(tags),
197- CreatedAt: sql.NullTime{Time: t, Valid: true},
198- CID: sql.NullString{String: event.CID, Valid: event.CID != ""},
199 }201 }
200- return h.articles.CreateAnnotation(ctx, a)202+ return h.articles.UpdateAnnotation(ctx, a)
201 203
202 case actionDelete:204 case actionDelete:
203 }205 }
204 return nil206 return nil
205 }207 }
206 208
209+func (h *StreamDBHandler) parseMarginNoteRecord(ctx context.Context, event *Event) (*db.Annotation, error) {
210+ var rec MarginNoteRecord
211+ if err := json.Unmarshal(event.Value, &rec); err != nil {
212+ return nil, err
213+ }
214+
215+ articleURL, quote, note, tags := rec.ToAnnotation()
216+ if articleURL == "" {
217+ return nil, nil
218+ }
219+
220+ feedURL := h.resolveFeedURL(ctx, articleURL)
221+
222+ t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
223+ return &db.Annotation{
224+ URI: event.URI,
225+ AuthorDID: event.DID,
226+ FeedURL: feedURL,
227+ ArticleURL: articleURL,
228+ Quote: db.NullStr(quote),
229+ Note: db.NullStr(note),
230+ Tags: db.NullStrTags(tags),
231+ CreatedAt: sql.NullTime{Time: t, Valid: true},
232+ CID: sql.NullString{String: event.CID, Valid: event.CID != ""},
233+ }, nil
234+}
235+
207 func (h *StreamDBHandler) handleSkyreaderSubscription(ctx context.Context, event *Event) error {236 func (h *StreamDBHandler) handleSkyreaderSubscription(ctx context.Context, event *Event) error {
208 switch event.Type {237 switch event.Type {
209 case actionCreate, actionUpdate:238 case actionCreate, actionUpdate:
modified internal/db/batch_test.go +57 -0
@@ -162,6 +162,63 @@ func TestBatchReconcileSubscriptions_SkipsExistingWithURI(t *testing.T) {
162162 assert.Equal(t, s.URI.String, "at://existing")
163163 }
164164
165+func TestBatchReconcileSubscriptions_UpdatesCategoryAndTitle(t *testing.T) {
166+ ctx := context.Background()
167+ dbs := setupTestDB(t)
168+ userDID := seedSubscriptionData(t, ctx, dbs)
169+
170+ _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")})
171+ err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "old-cat", "at://existing", "cid")
172+ assert.NilError(t, err)
173+
174+ subs := []SubData{
175+ {FeedURL: "https://a.com/feed.xml", Title: "New Title", Category: "new-cat", URI: "at://existing", CID: "cid"},
176+ }
177+ err = dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs)
178+ assert.NilError(t, err)
179+
180+ s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml")
181+ assert.NilError(t, err)
182+ assert.Equal(t, s.Category.String, "new-cat")
183+ assert.Equal(t, s.FeedTitle, "New Title")
184+
185+ f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml")
186+ assert.NilError(t, err)
187+ assert.Equal(t, f.SubscriberCount, 1)
188+}
189+
190+func TestCreateSubscription_UpdatesCategoryAndTitle(t *testing.T) {
191+ ctx := context.Background()
192+ dbs := setupTestDB(t)
193+ userDID := seedSubscriptionData(t, ctx, dbs)
194+
195+ _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")})
196+ err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "old-cat", "at://existing", "cid")
197+ assert.NilError(t, err)
198+
199+ err = dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "New Title", "new-cat", "at://existing", "cid2")
200+ assert.NilError(t, err)
201+
202+ s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml")
203+ assert.NilError(t, err)
204+ assert.Equal(t, s.Category.String, "new-cat")
205+ assert.Equal(t, s.FeedTitle, "New Title")
206+ assert.Equal(t, s.CID.String, "cid2")
207+}
208+
209+func TestCreateSubscription_ReturnsDuplicateWhenUnchanged(t *testing.T) {
210+ ctx := context.Background()
211+ dbs := setupTestDB(t)
212+ userDID := seedSubscriptionData(t, ctx, dbs)
213+
214+ _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")})
215+ err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "cat", "at://existing", "cid")
216+ assert.NilError(t, err)
217+
218+ err = dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "cat", "at://existing", "cid")
219+ assert.Equal(t, err, ErrDuplicateSubscription)
220+}
221+
165222 func TestBatchCreateLikes_InsertsAll(t *testing.T) {
166223 ctx := context.Background()
167224 dbs := setupTestDB(t)
@@ -162,6 +162,63 @@ func TestBatchReconcileSubscriptions_SkipsExistingWithURI(t *testing.T) {
162 assert.Equal(t, s.URI.String, "at://existing")162 assert.Equal(t, s.URI.String, "at://existing")
163 }163 }
164 164
165+func TestBatchReconcileSubscriptions_UpdatesCategoryAndTitle(t *testing.T) {
166+ ctx := context.Background()
167+ dbs := setupTestDB(t)
168+ userDID := seedSubscriptionData(t, ctx, dbs)
169+
170+ _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")})
171+ err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "old-cat", "at://existing", "cid")
172+ assert.NilError(t, err)
173+
174+ subs := []SubData{
175+ {FeedURL: "https://a.com/feed.xml", Title: "New Title", Category: "new-cat", URI: "at://existing", CID: "cid"},
176+ }
177+ err = dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs)
178+ assert.NilError(t, err)
179+
180+ s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml")
181+ assert.NilError(t, err)
182+ assert.Equal(t, s.Category.String, "new-cat")
183+ assert.Equal(t, s.FeedTitle, "New Title")
184+
185+ f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml")
186+ assert.NilError(t, err)
187+ assert.Equal(t, f.SubscriberCount, 1)
188+}
189+
190+func TestCreateSubscription_UpdatesCategoryAndTitle(t *testing.T) {
191+ ctx := context.Background()
192+ dbs := setupTestDB(t)
193+ userDID := seedSubscriptionData(t, ctx, dbs)
194+
195+ _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")})
196+ err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "old-cat", "at://existing", "cid")
197+ assert.NilError(t, err)
198+
199+ err = dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "New Title", "new-cat", "at://existing", "cid2")
200+ assert.NilError(t, err)
201+
202+ s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml")
203+ assert.NilError(t, err)
204+ assert.Equal(t, s.Category.String, "new-cat")
205+ assert.Equal(t, s.FeedTitle, "New Title")
206+ assert.Equal(t, s.CID.String, "cid2")
207+}
208+
209+func TestCreateSubscription_ReturnsDuplicateWhenUnchanged(t *testing.T) {
210+ ctx := context.Background()
211+ dbs := setupTestDB(t)
212+ userDID := seedSubscriptionData(t, ctx, dbs)
213+
214+ _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")})
215+ err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "cat", "at://existing", "cid")
216+ assert.NilError(t, err)
217+
218+ err = dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "cat", "at://existing", "cid")
219+ assert.Equal(t, err, ErrDuplicateSubscription)
220+}
221+
165 func TestBatchCreateLikes_InsertsAll(t *testing.T) {222 func TestBatchCreateLikes_InsertsAll(t *testing.T) {
166 ctx := context.Background()223 ctx := context.Background()
167 dbs := setupTestDB(t)224 dbs := setupTestDB(t)
modified internal/db/feed.go +29 -12
@@ -121,19 +121,24 @@ func (s *ArticleStore) decrementSubscriberCount(ctx context.Context, feedURL str
121121
122122 func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error {
123123 existing, err := s.GetSubscription(ctx, userDID, feedURL)
124- if err == nil && existing != nil {
125- if !existing.URI.Valid || existing.URI.String == "" {
126- return s.updateSubscriptionURI(ctx, userDID, feedURL, uri, cid)
127- }
124+ if err != nil || existing == nil {
125+ return s.BatchReconcileSubscriptions(ctx, userDID, []SubData{{FeedURL: feedURL, Title: title, Category: category, URI: uri, CID: cid}})
126+ }
127+
128+ unchanged := existing.FeedTitle == title && existing.Category.String == category && existing.CID.String == cid
129+ if unchanged {
128130 return ErrDuplicateSubscription
129131 }
130- return s.BatchReconcileSubscriptions(ctx, userDID, []SubData{{FeedURL: feedURL, Title: title, Category: category, URI: uri, CID: cid}})
131-}
132132
133-func (s *ArticleStore) updateSubscriptionURI(ctx context.Context, userDID, feedURL, uri, cid string) error {
134- _, err := s.db.ExecContext(ctx, `
135- UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?
136- `, uri, cid, userDID, feedURL)
133+ if !existing.URI.Valid || existing.URI.String == "" {
134+ _, err := s.db.ExecContext(ctx, `
135+ UPDATE articles.subscriptions SET title = ?, category = ?, uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?
136+ `, nilIfEmpty(title), nilIfEmpty(category), nilIfEmpty(uri), nilIfEmpty(cid), userDID, feedURL)
137+ return err
138+ }
139+ _, err = s.db.ExecContext(ctx, `
140+ UPDATE articles.subscriptions SET title = ?, category = ?, cid = ? WHERE user_did = ? AND feed_url = ?
141+ `, nilIfEmpty(title), nilIfEmpty(category), nilIfEmpty(cid), userDID, feedURL)
137142 return err
138143 }
139144
@@ -411,8 +416,16 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
411416 }
412417 defer insertStmt.Close()
413418
419+ backfillStmt, err := tx.PrepareContext(ctx, `
420+ UPDATE articles.subscriptions SET title = ?, category = ?, uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?
421+ `)
422+ if err != nil {
423+ return err
424+ }
425+ defer backfillStmt.Close()
426+
414427 updateStmt, err := tx.PrepareContext(ctx, `
415- UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?
428+ UPDATE articles.subscriptions SET title = ?, category = ?, cid = ? WHERE user_did = ? AND feed_url = ?
416429 `)
417430 if err != nil {
418431 return err
@@ -428,7 +441,11 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
428441 for _, sub := range subs {
429442 if existingURI, ok := existing[sub.FeedURL]; ok {
430443 if existingURI == "" && sub.URI != "" {
431- if _, err := updateStmt.ExecContext(ctx, sub.URI, sub.CID, userDID, sub.FeedURL); err != nil {
444+ if _, err := backfillStmt.ExecContext(ctx, nilIfEmpty(sub.Title), nilIfEmpty(sub.Category), sub.URI, nilIfEmpty(sub.CID), userDID, sub.FeedURL); err != nil {
445+ return err
446+ }
447+ } else if existingURI == sub.URI {
448+ if _, err := updateStmt.ExecContext(ctx, nilIfEmpty(sub.Title), nilIfEmpty(sub.Category), nilIfEmpty(sub.CID), userDID, sub.FeedURL); err != nil {
432449 return err
433450 }
434451 }
@@ -121,19 +121,24 @@ func (s *ArticleStore) decrementSubscriberCount(ctx context.Context, feedURL str
121 121
122 func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error {122 func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error {
123 existing, err := s.GetSubscription(ctx, userDID, feedURL)123 existing, err := s.GetSubscription(ctx, userDID, feedURL)
124- if err == nil && existing != nil {124+ if err != nil || existing == nil {
125- if !existing.URI.Valid || existing.URI.String == "" {125+ return s.BatchReconcileSubscriptions(ctx, userDID, []SubData{{FeedURL: feedURL, Title: title, Category: category, URI: uri, CID: cid}})
126- return s.updateSubscriptionURI(ctx, userDID, feedURL, uri, cid)126+ }
127- }127+
128+ unchanged := existing.FeedTitle == title && existing.Category.String == category && existing.CID.String == cid
129+ if unchanged {
128 return ErrDuplicateSubscription130 return ErrDuplicateSubscription
129 }131 }
130- return s.BatchReconcileSubscriptions(ctx, userDID, []SubData{{FeedURL: feedURL, Title: title, Category: category, URI: uri, CID: cid}})
131-}
132 132
133-func (s *ArticleStore) updateSubscriptionURI(ctx context.Context, userDID, feedURL, uri, cid string) error {133+ if !existing.URI.Valid || existing.URI.String == "" {
134- _, err := s.db.ExecContext(ctx, `134+ _, err := s.db.ExecContext(ctx, `
135- UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?135+ UPDATE articles.subscriptions SET title = ?, category = ?, uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?
136- `, uri, cid, userDID, feedURL)136+ `, nilIfEmpty(title), nilIfEmpty(category), nilIfEmpty(uri), nilIfEmpty(cid), userDID, feedURL)
137+ return err
138+ }
139+ _, err = s.db.ExecContext(ctx, `
140+ UPDATE articles.subscriptions SET title = ?, category = ?, cid = ? WHERE user_did = ? AND feed_url = ?
141+ `, nilIfEmpty(title), nilIfEmpty(category), nilIfEmpty(cid), userDID, feedURL)
137 return err142 return err
138 }143 }
139 144
@@ -411,8 +416,16 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
411 }416 }
412 defer insertStmt.Close()417 defer insertStmt.Close()
413 418
419+ backfillStmt, err := tx.PrepareContext(ctx, `
420+ UPDATE articles.subscriptions SET title = ?, category = ?, uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?
421+ `)
422+ if err != nil {
423+ return err
424+ }
425+ defer backfillStmt.Close()
426+
414 updateStmt, err := tx.PrepareContext(ctx, `427 updateStmt, err := tx.PrepareContext(ctx, `
415- UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?428+ UPDATE articles.subscriptions SET title = ?, category = ?, cid = ? WHERE user_did = ? AND feed_url = ?
416 `)429 `)
417 if err != nil {430 if err != nil {
418 return err431 return err
@@ -428,7 +441,11 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
428 for _, sub := range subs {441 for _, sub := range subs {
429 if existingURI, ok := existing[sub.FeedURL]; ok {442 if existingURI, ok := existing[sub.FeedURL]; ok {
430 if existingURI == "" && sub.URI != "" {443 if existingURI == "" && sub.URI != "" {
431- if _, err := updateStmt.ExecContext(ctx, sub.URI, sub.CID, userDID, sub.FeedURL); err != nil {444+ if _, err := backfillStmt.ExecContext(ctx, nilIfEmpty(sub.Title), nilIfEmpty(sub.Category), sub.URI, nilIfEmpty(sub.CID), userDID, sub.FeedURL); err != nil {
445+ return err
446+ }
447+ } else if existingURI == sub.URI {
448+ if _, err := updateStmt.ExecContext(ctx, nilIfEmpty(sub.Title), nilIfEmpty(sub.Category), nilIfEmpty(sub.CID), userDID, sub.FeedURL); err != nil {
432 return err449 return err
433 }450 }
434 }451 }
modified internal/db/social.go +8 -0
@@ -39,6 +39,14 @@ func (s *ArticleStore) CreateAnnotation(ctx context.Context, a *Annotation) erro
3939 return s.BatchCreateAnnotations(ctx, []*Annotation{a})
4040 }
4141
42+func (s *ArticleStore) UpdateAnnotation(ctx context.Context, a *Annotation) error {
43+ _, err := s.db.ExecContext(ctx, `
44+ UPDATE articles.annotations SET quote = ?, note = ?, tags = ?, rating = ?, cid = ?
45+ WHERE uri = ?
46+ `, a.Quote, a.Note, a.Tags, a.Rating, a.CID, a.URI)
47+ return err
48+}
49+
4250 func (s *ArticleStore) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) {
4351 a := &Annotation{}
4452 err := s.db.QueryRowContext(ctx, `
@@ -39,6 +39,14 @@ func (s *ArticleStore) CreateAnnotation(ctx context.Context, a *Annotation) erro
39 return s.BatchCreateAnnotations(ctx, []*Annotation{a})39 return s.BatchCreateAnnotations(ctx, []*Annotation{a})
40 }40 }
41 41
42+func (s *ArticleStore) UpdateAnnotation(ctx context.Context, a *Annotation) error {
43+ _, err := s.db.ExecContext(ctx, `
44+ UPDATE articles.annotations SET quote = ?, note = ?, tags = ?, rating = ?, cid = ?
45+ WHERE uri = ?
46+ `, a.Quote, a.Note, a.Tags, a.Rating, a.CID, a.URI)
47+ return err
48+}
49+
42 func (s *ArticleStore) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) {50 func (s *ArticleStore) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) {
43 a := &Annotation{}51 a := &Annotation{}
44 err := s.db.QueryRowContext(ctx, `52 err := s.db.QueryRowContext(ctx, `