fix(annotations): prevent duplicate annotations and mirror cleanupUnverified
7f52a94 parent: d64ded7 modified
internal/atproto/sync.go +49 -0 | @@ -164,6 +164,7 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | ||
| 164 | 164 | |
| 165 | 165 | var annotations []*db.Annotation |
| 166 | 166 | activeURIs := make(map[string]bool) |
| 167 | + seenContent := make(map[string]bool) | |
| 167 | 168 | |
| 168 | 169 | for _, r := range annRecs { |
| 169 | 170 | var rec AnnotationRecord |
| @@ -174,6 +175,7 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | ||
| 174 | 175 | continue |
| 175 | 176 | } |
| 176 | 177 | activeURIs[r.URI] = true |
| 178 | + seenContent[annotationContentKey(userDID, rec.ArticleURL, rec.Quote, rec.Note)] = true | |
| 177 | 179 | t := parseRFC3339(rec.CreatedAt) |
| 178 | 180 | a := &db.Annotation{ |
| 179 | 181 | URI: r.URI, |
| @@ -201,6 +203,14 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | ||
| 201 | 203 | if articleURL == "" { |
| 202 | 204 | continue |
| 203 | 205 | } |
| 206 | + | |
| 207 | + // A margin note that mirrors an existing glean annotation is skipped so it | |
| 208 | + // does not show up as a duplicate. Its URI is intentionally not tracked as | |
| 209 | + // active, so any stale duplicate row gets removed by orphan cleanup. | |
| 210 | + if seenContent[annotationContentKey(userDID, articleURL, quote, note)] { | |
| 211 | + continue | |
| 212 | + } | |
| 213 | + | |
| 204 | 214 | activeURIs[r.URI] = true |
| 205 | 215 | |
| 206 | 216 | feedURL := "" |
| @@ -266,6 +276,45 @@ func (s *Sync) syncFollows(ctx context.Context, userDID string) error { | ||
| 266 | 276 | return s.users.SyncFollows(ctx, userDID, activeFollows) |
| 267 | 277 | } |
| 268 | 278 | |
| 279 | +// annotationContentKey is the identity used to detect that a margin note mirrors | |
| 280 | +// a glean annotation. It mirrors ArticleStore.AnnotationExistsByContent. | |
| 281 | +func annotationContentKey(authorDID, articleURL, quote, note string) string { | |
| 282 | + return authorDID + "\x1f" + articleURL + "\x1f" + quote + "\x1f" + note | |
| 283 | +} | |
| 284 | + | |
| 285 | +// DeleteMirroredMarginNotes removes the user's at.margin.note records that mirror | |
| 286 | +// the given glean annotation (same article URL, quote and note). Glean mirrors | |
| 287 | +// every annotation it creates, so deleting an annotation must also delete its | |
| 288 | +// mirror or the next sync would resurrect it as a margin-note annotation. | |
| 289 | +func DeleteMirroredMarginNotes(ctx context.Context, client *Client, did, articleURL, quote, note string) error { | |
| 290 | + records, err := listAllRecords(ctx, client, did, CollectionMarginNote) | |
| 291 | + if err != nil { | |
| 292 | + return err | |
| 293 | + } | |
| 294 | + target := annotationContentKey(did, articleURL, quote, note) | |
| 295 | + for _, r := range records { | |
| 296 | + var rec MarginNoteRecord | |
| 297 | + if err := json.Unmarshal(r.Value, &rec); err != nil { | |
| 298 | + continue | |
| 299 | + } | |
| 300 | + rArticleURL, rQuote, rNote, _ := rec.ToAnnotation() | |
| 301 | + if rArticleURL == "" { | |
| 302 | + continue | |
| 303 | + } | |
| 304 | + if annotationContentKey(did, rArticleURL, rQuote, rNote) != target { | |
| 305 | + continue | |
| 306 | + } | |
| 307 | + parsed, ok := ParseRecordURI(r.URI) | |
| 308 | + if !ok { | |
| 309 | + continue | |
| 310 | + } | |
| 311 | + if err := client.DeleteRecord(ctx, did, CollectionMarginNote, parsed.RKey); err != nil { | |
| 312 | + return err | |
| 313 | + } | |
| 314 | + } | |
| 315 | + return nil | |
| 316 | +} | |
| 317 | + | |
| 269 | 318 | func (s *Sync) backfillMissingPDSRecords(ctx context.Context, userDID string) error { |
| 270 | 319 | subs, err := s.articles.ListSubscriptionsWithoutURI(ctx, userDID) |
| 271 | 320 | if err != nil { |
| @@ -164,6 +164,7 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | |||
| 164 | 164 | ||
| 165 | var annotations []*db.Annotation | 165 | var annotations []*db.Annotation |
| 166 | activeURIs := make(map[string]bool) | 166 | activeURIs := make(map[string]bool) |
| 167 | + seenContent := make(map[string]bool) | ||
| 167 | 168 | ||
| 168 | for _, r := range annRecs { | 169 | for _, r := range annRecs { |
| 169 | var rec AnnotationRecord | 170 | var rec AnnotationRecord |
| @@ -174,6 +175,7 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | |||
| 174 | continue | 175 | continue |
| 175 | } | 176 | } |
| 176 | activeURIs[r.URI] = true | 177 | activeURIs[r.URI] = true |
| 178 | + seenContent[annotationContentKey(userDID, rec.ArticleURL, rec.Quote, rec.Note)] = true | ||
| 177 | t := parseRFC3339(rec.CreatedAt) | 179 | t := parseRFC3339(rec.CreatedAt) |
| 178 | a := &db.Annotation{ | 180 | a := &db.Annotation{ |
| 179 | URI: r.URI, | 181 | URI: r.URI, |
| @@ -201,6 +203,14 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | |||
| 201 | if articleURL == "" { | 203 | if articleURL == "" { |
| 202 | continue | 204 | continue |
| 203 | } | 205 | } |
| 206 | + | ||
| 207 | + // A margin note that mirrors an existing glean annotation is skipped so it | ||
| 208 | + // does not show up as a duplicate. Its URI is intentionally not tracked as | ||
| 209 | + // active, so any stale duplicate row gets removed by orphan cleanup. | ||
| 210 | + if seenContent[annotationContentKey(userDID, articleURL, quote, note)] { | ||
| 211 | + continue | ||
| 212 | + } | ||
| 213 | + | ||
| 204 | activeURIs[r.URI] = true | 214 | activeURIs[r.URI] = true |
| 205 | 215 | ||
| 206 | feedURL := "" | 216 | feedURL := "" |
| @@ -266,6 +276,45 @@ func (s *Sync) syncFollows(ctx context.Context, userDID string) error { | |||
| 266 | return s.users.SyncFollows(ctx, userDID, activeFollows) | 276 | return s.users.SyncFollows(ctx, userDID, activeFollows) |
| 267 | } | 277 | } |
| 268 | 278 | ||
| 279 | +// annotationContentKey is the identity used to detect that a margin note mirrors | ||
| 280 | +// a glean annotation. It mirrors ArticleStore.AnnotationExistsByContent. | ||
| 281 | +func annotationContentKey(authorDID, articleURL, quote, note string) string { | ||
| 282 | + return authorDID + "\x1f" + articleURL + "\x1f" + quote + "\x1f" + note | ||
| 283 | +} | ||
| 284 | + | ||
| 285 | +// DeleteMirroredMarginNotes removes the user's at.margin.note records that mirror | ||
| 286 | +// the given glean annotation (same article URL, quote and note). Glean mirrors | ||
| 287 | +// every annotation it creates, so deleting an annotation must also delete its | ||
| 288 | +// mirror or the next sync would resurrect it as a margin-note annotation. | ||
| 289 | +func DeleteMirroredMarginNotes(ctx context.Context, client *Client, did, articleURL, quote, note string) error { | ||
| 290 | + records, err := listAllRecords(ctx, client, did, CollectionMarginNote) | ||
| 291 | + if err != nil { | ||
| 292 | + return err | ||
| 293 | + } | ||
| 294 | + target := annotationContentKey(did, articleURL, quote, note) | ||
| 295 | + for _, r := range records { | ||
| 296 | + var rec MarginNoteRecord | ||
| 297 | + if err := json.Unmarshal(r.Value, &rec); err != nil { | ||
| 298 | + continue | ||
| 299 | + } | ||
| 300 | + rArticleURL, rQuote, rNote, _ := rec.ToAnnotation() | ||
| 301 | + if rArticleURL == "" { | ||
| 302 | + continue | ||
| 303 | + } | ||
| 304 | + if annotationContentKey(did, rArticleURL, rQuote, rNote) != target { | ||
| 305 | + continue | ||
| 306 | + } | ||
| 307 | + parsed, ok := ParseRecordURI(r.URI) | ||
| 308 | + if !ok { | ||
| 309 | + continue | ||
| 310 | + } | ||
| 311 | + if err := client.DeleteRecord(ctx, did, CollectionMarginNote, parsed.RKey); err != nil { | ||
| 312 | + return err | ||
| 313 | + } | ||
| 314 | + } | ||
| 315 | + return nil | ||
| 316 | +} | ||
| 317 | + | ||
| 269 | func (s *Sync) backfillMissingPDSRecords(ctx context.Context, userDID string) error { | 318 | func (s *Sync) backfillMissingPDSRecords(ctx context.Context, userDID string) error { |
| 270 | subs, err := s.articles.ListSubscriptionsWithoutURI(ctx, userDID) | 319 | subs, err := s.articles.ListSubscriptionsWithoutURI(ctx, userDID) |
| 271 | if err != nil { | 320 | if err != nil { |
added
internal/atproto/sync_test.go +222 -0 | new file mode 100644 | ||
| @@ -0,0 +1,222 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "log/slog" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "os" | |
| 10 | + "testing" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "gotest.tools/v3/assert" | |
| 14 | + | |
| 15 | + "pkg.rbrt.fr/glean/internal/db" | |
| 16 | +) | |
| 17 | + | |
| 18 | +func setupSyncTestDB(t *testing.T) *db.Store { | |
| 19 | + t.Helper() | |
| 20 | + f, err := os.CreateTemp("", "glean-sync-test-*.db") | |
| 21 | + assert.NilError(t, err) | |
| 22 | + assert.NilError(t, f.Close()) | |
| 23 | + path := f.Name() | |
| 24 | + t.Cleanup(func() { | |
| 25 | + for _, suffix := range []string{"", "_users", "_users-shm", "_users-wal", "_articles", "_articles-shm", "_articles-wal", "_recs", "_recs-shm", "_recs-wal"} { | |
| 26 | + _ = os.Remove(path + suffix) | |
| 27 | + } | |
| 28 | + }) | |
| 29 | + dbs, err := db.Open(path) | |
| 30 | + assert.NilError(t, err) | |
| 31 | + t.Cleanup(func() { _ = dbs.Close() }) | |
| 32 | + return dbs | |
| 33 | +} | |
| 34 | + | |
| 35 | +type rawRecord struct { | |
| 36 | + URI string `json:"uri"` | |
| 37 | + CID string `json:"cid"` | |
| 38 | + Value json.RawMessage `json:"value"` | |
| 39 | +} | |
| 40 | + | |
| 41 | +// newSyncTestServer returns an httptest PDS that replies to listRecords for the | |
| 42 | +// given collection -> records map. | |
| 43 | +func newSyncTestServer(t *testing.T, byCollection map[string][]Record) *httptest.Server { | |
| 44 | + t.Helper() | |
| 45 | + mux := http.NewServeMux() | |
| 46 | + mux.HandleFunc("/xrpc/", func(w http.ResponseWriter, r *http.Request) { | |
| 47 | + w.Header().Set("Content-Type", "application/json") | |
| 48 | + var raw []rawRecord | |
| 49 | + for _, rec := range byCollection[r.URL.Query().Get("collection")] { | |
| 50 | + raw = append(raw, rawRecord{URI: rec.URI, CID: rec.CID, Value: rec.Value}) | |
| 51 | + } | |
| 52 | + _ = json.NewEncoder(w).Encode(map[string]any{"records": raw}) | |
| 53 | + }) | |
| 54 | + server := httptest.NewServer(mux) | |
| 55 | + t.Cleanup(server.Close) | |
| 56 | + return server | |
| 57 | +} | |
| 58 | + | |
| 59 | +func TestSyncAnnotations_DedupsMirroredMarginNote(t *testing.T) { | |
| 60 | + ctx := context.Background() | |
| 61 | + dbs := setupSyncTestDB(t) | |
| 62 | + | |
| 63 | + assert.NilError(t, dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"})) | |
| 64 | + | |
| 65 | + now := time.Now().Format(time.RFC3339) | |
| 66 | + annValue, err := json.Marshal(AnnotationRecord{ | |
| 67 | + CreatedAt: now, FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | |
| 68 | + Quote: "selected text", Note: "my note", | |
| 69 | + }) | |
| 70 | + assert.NilError(t, err) | |
| 71 | + | |
| 72 | + marginRec := NewMarginNoteRecord("https://a.com/1", "selected text", "my note", nil, now) | |
| 73 | + marginValue, err := json.Marshal(marginRec) | |
| 74 | + assert.NilError(t, err) | |
| 75 | + | |
| 76 | + server := newSyncTestServer(t, map[string][]Record{ | |
| 77 | + CollectionAnnotation: { | |
| 78 | + {URI: "at://did:test:u1/at.glean.annotation/rkey1", CID: "cid-a1", Value: annValue}, | |
| 79 | + }, | |
| 80 | + CollectionMarginNote: { | |
| 81 | + {URI: "at://did:test:u1/at.margin.note/rkey1", CID: "cid-m1", Value: marginValue}, | |
| 82 | + }, | |
| 83 | + }) | |
| 84 | + | |
| 85 | + sync := NewSync(dbs.Articles, dbs.Users, NewUnauthenticatedClient(server.URL), slog.Default()) | |
| 86 | + assert.NilError(t, sync.syncAnnotations(ctx, "did:test:u1")) | |
| 87 | + | |
| 88 | + anns, err := dbs.Articles.ListAnnotations(ctx, "", "", "", 100, 0) | |
| 89 | + assert.NilError(t, err) | |
| 90 | + assert.Equal(t, len(anns), 1, "mirrored margin note should not produce a duplicate row") | |
| 91 | + if len(anns) == 1 { | |
| 92 | + assert.Equal(t, anns[0].URI, "at://did:test:u1/at.glean.annotation/rkey1") | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +func TestSyncAnnotations_KeepsExternalMarginNote(t *testing.T) { | |
| 97 | + ctx := context.Background() | |
| 98 | + dbs := setupSyncTestDB(t) | |
| 99 | + | |
| 100 | + assert.NilError(t, dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"})) | |
| 101 | + | |
| 102 | + now := time.Now().Format(time.RFC3339) | |
| 103 | + marginRec := NewMarginNoteRecord("https://a.com/1", "some quote", "some note", nil, now) | |
| 104 | + marginValue, err := json.Marshal(marginRec) | |
| 105 | + assert.NilError(t, err) | |
| 106 | + | |
| 107 | + server := newSyncTestServer(t, map[string][]Record{ | |
| 108 | + CollectionAnnotation: {}, // no glean annotation | |
| 109 | + CollectionMarginNote: { | |
| 110 | + {URI: "at://did:test:u1/at.margin.note/rkey1", CID: "cid-m1", Value: marginValue}, | |
| 111 | + }, | |
| 112 | + }) | |
| 113 | + | |
| 114 | + sync := NewSync(dbs.Articles, dbs.Users, NewUnauthenticatedClient(server.URL), slog.Default()) | |
| 115 | + assert.NilError(t, sync.syncAnnotations(ctx, "did:test:u1")) | |
| 116 | + | |
| 117 | + anns, err := dbs.Articles.ListAnnotations(ctx, "", "", "", 100, 0) | |
| 118 | + assert.NilError(t, err) | |
| 119 | + assert.Equal(t, len(anns), 1, "external margin note should be kept") | |
| 120 | + assert.Equal(t, anns[0].URI, "at://did:test:u1/at.margin.note/rkey1") | |
| 121 | +} | |
| 122 | + | |
| 123 | +func TestSyncAnnotations_DedupsNoteOnlyMarginNote(t *testing.T) { | |
| 124 | + ctx := context.Background() | |
| 125 | + dbs := setupSyncTestDB(t) | |
| 126 | + | |
| 127 | + assert.NilError(t, dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"})) | |
| 128 | + | |
| 129 | + now := time.Now().Format(time.RFC3339) | |
| 130 | + annValue, err := json.Marshal(AnnotationRecord{ | |
| 131 | + CreatedAt: now, FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | |
| 132 | + Note: "just a comment", | |
| 133 | + }) | |
| 134 | + assert.NilError(t, err) | |
| 135 | + | |
| 136 | + marginRec := NewMarginNoteRecord("https://a.com/1", "", "just a comment", nil, now) | |
| 137 | + marginValue, err := json.Marshal(marginRec) | |
| 138 | + assert.NilError(t, err) | |
| 139 | + | |
| 140 | + server := newSyncTestServer(t, map[string][]Record{ | |
| 141 | + CollectionAnnotation: { | |
| 142 | + {URI: "at://did:test:u1/at.glean.annotation/rkey1", CID: "cid-a1", Value: annValue}, | |
| 143 | + }, | |
| 144 | + CollectionMarginNote: { | |
| 145 | + {URI: "at://did:test:u1/at.margin.note/rkey1", CID: "cid-m1", Value: marginValue}, | |
| 146 | + }, | |
| 147 | + }) | |
| 148 | + | |
| 149 | + sync := NewSync(dbs.Articles, dbs.Users, NewUnauthenticatedClient(server.URL), slog.Default()) | |
| 150 | + assert.NilError(t, sync.syncAnnotations(ctx, "did:test:u1")) | |
| 151 | + | |
| 152 | + anns, err := dbs.Articles.ListAnnotations(ctx, "", "", "", 100, 0) | |
| 153 | + assert.NilError(t, err) | |
| 154 | + assert.Equal(t, len(anns), 1, "note-only mirrored margin note should not duplicate") | |
| 155 | +} | |
| 156 | + | |
| 157 | +func TestDeleteMirroredMarginNote_DeletesMatchingContent(t *testing.T) { | |
| 158 | + ctx := context.Background() | |
| 159 | + now := time.Now().Format(time.RFC3339) | |
| 160 | + | |
| 161 | + matching := NewMarginNoteRecord("https://a.com/1", "selected text", "my note", nil, now) | |
| 162 | + matchingValue, err := json.Marshal(matching) | |
| 163 | + assert.NilError(t, err) | |
| 164 | + other := NewMarginNoteRecord("https://a.com/2", "other", "other note", nil, now) | |
| 165 | + otherValue, err := json.Marshal(other) | |
| 166 | + assert.NilError(t, err) | |
| 167 | + | |
| 168 | + var deleted []string | |
| 169 | + mux := http.NewServeMux() | |
| 170 | + mux.HandleFunc("/xrpc/com.atproto.repo.listRecords", func(w http.ResponseWriter, r *http.Request) { | |
| 171 | + w.Header().Set("Content-Type", "application/json") | |
| 172 | + _ = json.NewEncoder(w).Encode(map[string]any{"records": []rawRecord{ | |
| 173 | + {URI: "at://did:test:u1/at.margin.note/match", CID: "c1", Value: matchingValue}, | |
| 174 | + {URI: "at://did:test:u1/at.margin.note/other", CID: "c2", Value: otherValue}, | |
| 175 | + }}) | |
| 176 | + }) | |
| 177 | + mux.HandleFunc("/xrpc/com.atproto.repo.deleteRecord", func(w http.ResponseWriter, r *http.Request) { | |
| 178 | + var body map[string]any | |
| 179 | + _ = json.NewDecoder(r.Body).Decode(&body) | |
| 180 | + deleted = append(deleted, body["rkey"].(string)) | |
| 181 | + w.WriteHeader(http.StatusOK) | |
| 182 | + _ = json.NewEncoder(w).Encode(map[string]any{}) | |
| 183 | + }) | |
| 184 | + server := httptest.NewServer(mux) | |
| 185 | + t.Cleanup(server.Close) | |
| 186 | + | |
| 187 | + client := NewUnauthenticatedClient(server.URL) | |
| 188 | + err = DeleteMirroredMarginNotes(ctx, client, "did:test:u1", "https://a.com/1", "selected text", "my note") | |
| 189 | + assert.NilError(t, err) | |
| 190 | + | |
| 191 | + assert.Equal(t, len(deleted), 1) | |
| 192 | + assert.Equal(t, deleted[0], "match") | |
| 193 | +} | |
| 194 | + | |
| 195 | +func TestDeleteMirroredMarginNote_NoMatchIsNoop(t *testing.T) { | |
| 196 | + ctx := context.Background() | |
| 197 | + now := time.Now().Format(time.RFC3339) | |
| 198 | + | |
| 199 | + other := NewMarginNoteRecord("https://a.com/2", "other", "other note", nil, now) | |
| 200 | + otherValue, err := json.Marshal(other) | |
| 201 | + assert.NilError(t, err) | |
| 202 | + | |
| 203 | + deleteCalled := false | |
| 204 | + mux := http.NewServeMux() | |
| 205 | + mux.HandleFunc("/xrpc/com.atproto.repo.listRecords", func(w http.ResponseWriter, r *http.Request) { | |
| 206 | + w.Header().Set("Content-Type", "application/json") | |
| 207 | + _ = json.NewEncoder(w).Encode(map[string]any{"records": []rawRecord{ | |
| 208 | + {URI: "at://did:test:u1/at.margin.note/other", CID: "c2", Value: otherValue}, | |
| 209 | + }}) | |
| 210 | + }) | |
| 211 | + mux.HandleFunc("/xrpc/com.atproto.repo.deleteRecord", func(w http.ResponseWriter, r *http.Request) { | |
| 212 | + deleteCalled = true | |
| 213 | + w.WriteHeader(http.StatusOK) | |
| 214 | + }) | |
| 215 | + server := httptest.NewServer(mux) | |
| 216 | + t.Cleanup(server.Close) | |
| 217 | + | |
| 218 | + client := NewUnauthenticatedClient(server.URL) | |
| 219 | + err = DeleteMirroredMarginNotes(ctx, client, "did:test:u1", "https://a.com/1", "selected text", "my note") | |
| 220 | + assert.NilError(t, err) | |
| 221 | + assert.Equal(t, deleteCalled, false) | |
| 222 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,222 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "log/slog" | ||
| 7 | + "net/http" | ||
| 8 | + "net/http/httptest" | ||
| 9 | + "os" | ||
| 10 | + "testing" | ||
| 11 | + "time" | ||
| 12 | + | ||
| 13 | + "gotest.tools/v3/assert" | ||
| 14 | + | ||
| 15 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +func setupSyncTestDB(t *testing.T) *db.Store { | ||
| 19 | + t.Helper() | ||
| 20 | + f, err := os.CreateTemp("", "glean-sync-test-*.db") | ||
| 21 | + assert.NilError(t, err) | ||
| 22 | + assert.NilError(t, f.Close()) | ||
| 23 | + path := f.Name() | ||
| 24 | + t.Cleanup(func() { | ||
| 25 | + for _, suffix := range []string{"", "_users", "_users-shm", "_users-wal", "_articles", "_articles-shm", "_articles-wal", "_recs", "_recs-shm", "_recs-wal"} { | ||
| 26 | + _ = os.Remove(path + suffix) | ||
| 27 | + } | ||
| 28 | + }) | ||
| 29 | + dbs, err := db.Open(path) | ||
| 30 | + assert.NilError(t, err) | ||
| 31 | + t.Cleanup(func() { _ = dbs.Close() }) | ||
| 32 | + return dbs | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +type rawRecord struct { | ||
| 36 | + URI string `json:"uri"` | ||
| 37 | + CID string `json:"cid"` | ||
| 38 | + Value json.RawMessage `json:"value"` | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +// newSyncTestServer returns an httptest PDS that replies to listRecords for the | ||
| 42 | +// given collection -> records map. | ||
| 43 | +func newSyncTestServer(t *testing.T, byCollection map[string][]Record) *httptest.Server { | ||
| 44 | + t.Helper() | ||
| 45 | + mux := http.NewServeMux() | ||
| 46 | + mux.HandleFunc("/xrpc/", func(w http.ResponseWriter, r *http.Request) { | ||
| 47 | + w.Header().Set("Content-Type", "application/json") | ||
| 48 | + var raw []rawRecord | ||
| 49 | + for _, rec := range byCollection[r.URL.Query().Get("collection")] { | ||
| 50 | + raw = append(raw, rawRecord{URI: rec.URI, CID: rec.CID, Value: rec.Value}) | ||
| 51 | + } | ||
| 52 | + _ = json.NewEncoder(w).Encode(map[string]any{"records": raw}) | ||
| 53 | + }) | ||
| 54 | + server := httptest.NewServer(mux) | ||
| 55 | + t.Cleanup(server.Close) | ||
| 56 | + return server | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +func TestSyncAnnotations_DedupsMirroredMarginNote(t *testing.T) { | ||
| 60 | + ctx := context.Background() | ||
| 61 | + dbs := setupSyncTestDB(t) | ||
| 62 | + | ||
| 63 | + assert.NilError(t, dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"})) | ||
| 64 | + | ||
| 65 | + now := time.Now().Format(time.RFC3339) | ||
| 66 | + annValue, err := json.Marshal(AnnotationRecord{ | ||
| 67 | + CreatedAt: now, FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | ||
| 68 | + Quote: "selected text", Note: "my note", | ||
| 69 | + }) | ||
| 70 | + assert.NilError(t, err) | ||
| 71 | + | ||
| 72 | + marginRec := NewMarginNoteRecord("https://a.com/1", "selected text", "my note", nil, now) | ||
| 73 | + marginValue, err := json.Marshal(marginRec) | ||
| 74 | + assert.NilError(t, err) | ||
| 75 | + | ||
| 76 | + server := newSyncTestServer(t, map[string][]Record{ | ||
| 77 | + CollectionAnnotation: { | ||
| 78 | + {URI: "at://did:test:u1/at.glean.annotation/rkey1", CID: "cid-a1", Value: annValue}, | ||
| 79 | + }, | ||
| 80 | + CollectionMarginNote: { | ||
| 81 | + {URI: "at://did:test:u1/at.margin.note/rkey1", CID: "cid-m1", Value: marginValue}, | ||
| 82 | + }, | ||
| 83 | + }) | ||
| 84 | + | ||
| 85 | + sync := NewSync(dbs.Articles, dbs.Users, NewUnauthenticatedClient(server.URL), slog.Default()) | ||
| 86 | + assert.NilError(t, sync.syncAnnotations(ctx, "did:test:u1")) | ||
| 87 | + | ||
| 88 | + anns, err := dbs.Articles.ListAnnotations(ctx, "", "", "", 100, 0) | ||
| 89 | + assert.NilError(t, err) | ||
| 90 | + assert.Equal(t, len(anns), 1, "mirrored margin note should not produce a duplicate row") | ||
| 91 | + if len(anns) == 1 { | ||
| 92 | + assert.Equal(t, anns[0].URI, "at://did:test:u1/at.glean.annotation/rkey1") | ||
| 93 | + } | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +func TestSyncAnnotations_KeepsExternalMarginNote(t *testing.T) { | ||
| 97 | + ctx := context.Background() | ||
| 98 | + dbs := setupSyncTestDB(t) | ||
| 99 | + | ||
| 100 | + assert.NilError(t, dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"})) | ||
| 101 | + | ||
| 102 | + now := time.Now().Format(time.RFC3339) | ||
| 103 | + marginRec := NewMarginNoteRecord("https://a.com/1", "some quote", "some note", nil, now) | ||
| 104 | + marginValue, err := json.Marshal(marginRec) | ||
| 105 | + assert.NilError(t, err) | ||
| 106 | + | ||
| 107 | + server := newSyncTestServer(t, map[string][]Record{ | ||
| 108 | + CollectionAnnotation: {}, // no glean annotation | ||
| 109 | + CollectionMarginNote: { | ||
| 110 | + {URI: "at://did:test:u1/at.margin.note/rkey1", CID: "cid-m1", Value: marginValue}, | ||
| 111 | + }, | ||
| 112 | + }) | ||
| 113 | + | ||
| 114 | + sync := NewSync(dbs.Articles, dbs.Users, NewUnauthenticatedClient(server.URL), slog.Default()) | ||
| 115 | + assert.NilError(t, sync.syncAnnotations(ctx, "did:test:u1")) | ||
| 116 | + | ||
| 117 | + anns, err := dbs.Articles.ListAnnotations(ctx, "", "", "", 100, 0) | ||
| 118 | + assert.NilError(t, err) | ||
| 119 | + assert.Equal(t, len(anns), 1, "external margin note should be kept") | ||
| 120 | + assert.Equal(t, anns[0].URI, "at://did:test:u1/at.margin.note/rkey1") | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +func TestSyncAnnotations_DedupsNoteOnlyMarginNote(t *testing.T) { | ||
| 124 | + ctx := context.Background() | ||
| 125 | + dbs := setupSyncTestDB(t) | ||
| 126 | + | ||
| 127 | + assert.NilError(t, dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"})) | ||
| 128 | + | ||
| 129 | + now := time.Now().Format(time.RFC3339) | ||
| 130 | + annValue, err := json.Marshal(AnnotationRecord{ | ||
| 131 | + CreatedAt: now, FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | ||
| 132 | + Note: "just a comment", | ||
| 133 | + }) | ||
| 134 | + assert.NilError(t, err) | ||
| 135 | + | ||
| 136 | + marginRec := NewMarginNoteRecord("https://a.com/1", "", "just a comment", nil, now) | ||
| 137 | + marginValue, err := json.Marshal(marginRec) | ||
| 138 | + assert.NilError(t, err) | ||
| 139 | + | ||
| 140 | + server := newSyncTestServer(t, map[string][]Record{ | ||
| 141 | + CollectionAnnotation: { | ||
| 142 | + {URI: "at://did:test:u1/at.glean.annotation/rkey1", CID: "cid-a1", Value: annValue}, | ||
| 143 | + }, | ||
| 144 | + CollectionMarginNote: { | ||
| 145 | + {URI: "at://did:test:u1/at.margin.note/rkey1", CID: "cid-m1", Value: marginValue}, | ||
| 146 | + }, | ||
| 147 | + }) | ||
| 148 | + | ||
| 149 | + sync := NewSync(dbs.Articles, dbs.Users, NewUnauthenticatedClient(server.URL), slog.Default()) | ||
| 150 | + assert.NilError(t, sync.syncAnnotations(ctx, "did:test:u1")) | ||
| 151 | + | ||
| 152 | + anns, err := dbs.Articles.ListAnnotations(ctx, "", "", "", 100, 0) | ||
| 153 | + assert.NilError(t, err) | ||
| 154 | + assert.Equal(t, len(anns), 1, "note-only mirrored margin note should not duplicate") | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +func TestDeleteMirroredMarginNote_DeletesMatchingContent(t *testing.T) { | ||
| 158 | + ctx := context.Background() | ||
| 159 | + now := time.Now().Format(time.RFC3339) | ||
| 160 | + | ||
| 161 | + matching := NewMarginNoteRecord("https://a.com/1", "selected text", "my note", nil, now) | ||
| 162 | + matchingValue, err := json.Marshal(matching) | ||
| 163 | + assert.NilError(t, err) | ||
| 164 | + other := NewMarginNoteRecord("https://a.com/2", "other", "other note", nil, now) | ||
| 165 | + otherValue, err := json.Marshal(other) | ||
| 166 | + assert.NilError(t, err) | ||
| 167 | + | ||
| 168 | + var deleted []string | ||
| 169 | + mux := http.NewServeMux() | ||
| 170 | + mux.HandleFunc("/xrpc/com.atproto.repo.listRecords", func(w http.ResponseWriter, r *http.Request) { | ||
| 171 | + w.Header().Set("Content-Type", "application/json") | ||
| 172 | + _ = json.NewEncoder(w).Encode(map[string]any{"records": []rawRecord{ | ||
| 173 | + {URI: "at://did:test:u1/at.margin.note/match", CID: "c1", Value: matchingValue}, | ||
| 174 | + {URI: "at://did:test:u1/at.margin.note/other", CID: "c2", Value: otherValue}, | ||
| 175 | + }}) | ||
| 176 | + }) | ||
| 177 | + mux.HandleFunc("/xrpc/com.atproto.repo.deleteRecord", func(w http.ResponseWriter, r *http.Request) { | ||
| 178 | + var body map[string]any | ||
| 179 | + _ = json.NewDecoder(r.Body).Decode(&body) | ||
| 180 | + deleted = append(deleted, body["rkey"].(string)) | ||
| 181 | + w.WriteHeader(http.StatusOK) | ||
| 182 | + _ = json.NewEncoder(w).Encode(map[string]any{}) | ||
| 183 | + }) | ||
| 184 | + server := httptest.NewServer(mux) | ||
| 185 | + t.Cleanup(server.Close) | ||
| 186 | + | ||
| 187 | + client := NewUnauthenticatedClient(server.URL) | ||
| 188 | + err = DeleteMirroredMarginNotes(ctx, client, "did:test:u1", "https://a.com/1", "selected text", "my note") | ||
| 189 | + assert.NilError(t, err) | ||
| 190 | + | ||
| 191 | + assert.Equal(t, len(deleted), 1) | ||
| 192 | + assert.Equal(t, deleted[0], "match") | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +func TestDeleteMirroredMarginNote_NoMatchIsNoop(t *testing.T) { | ||
| 196 | + ctx := context.Background() | ||
| 197 | + now := time.Now().Format(time.RFC3339) | ||
| 198 | + | ||
| 199 | + other := NewMarginNoteRecord("https://a.com/2", "other", "other note", nil, now) | ||
| 200 | + otherValue, err := json.Marshal(other) | ||
| 201 | + assert.NilError(t, err) | ||
| 202 | + | ||
| 203 | + deleteCalled := false | ||
| 204 | + mux := http.NewServeMux() | ||
| 205 | + mux.HandleFunc("/xrpc/com.atproto.repo.listRecords", func(w http.ResponseWriter, r *http.Request) { | ||
| 206 | + w.Header().Set("Content-Type", "application/json") | ||
| 207 | + _ = json.NewEncoder(w).Encode(map[string]any{"records": []rawRecord{ | ||
| 208 | + {URI: "at://did:test:u1/at.margin.note/other", CID: "c2", Value: otherValue}, | ||
| 209 | + }}) | ||
| 210 | + }) | ||
| 211 | + mux.HandleFunc("/xrpc/com.atproto.repo.deleteRecord", func(w http.ResponseWriter, r *http.Request) { | ||
| 212 | + deleteCalled = true | ||
| 213 | + w.WriteHeader(http.StatusOK) | ||
| 214 | + }) | ||
| 215 | + server := httptest.NewServer(mux) | ||
| 216 | + t.Cleanup(server.Close) | ||
| 217 | + | ||
| 218 | + client := NewUnauthenticatedClient(server.URL) | ||
| 219 | + err = DeleteMirroredMarginNotes(ctx, client, "did:test:u1", "https://a.com/1", "selected text", "my note") | ||
| 220 | + assert.NilError(t, err) | ||
| 221 | + assert.Equal(t, deleteCalled, false) | ||
| 222 | +} | ||
modified
internal/db/social.go +14 -0 | @@ -84,6 +84,20 @@ func (s *ArticleStore) DeleteAnnotation(ctx context.Context, uri string) error { | ||
| 84 | 84 | return err |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | +// DeleteAnnotationsByContent removes every annotation a user has for a given | |
| 88 | +// article + quote + note. A glean annotation and its at.margin.note mirror share | |
| 89 | +// this content, so deleting by content clears both rows even though they have | |
| 90 | +// different URIs. | |
| 91 | +func (s *ArticleStore) DeleteAnnotationsByContent(ctx context.Context, authorDID, articleURL, quote, note string) error { | |
| 92 | + _, err := s.db.ExecContext(ctx, ` | |
| 93 | + DELETE FROM articles.annotations | |
| 94 | + WHERE author_did = ? AND article_url = ? | |
| 95 | + AND COALESCE(quote, '') = COALESCE(?, '') | |
| 96 | + AND COALESCE(note, '') = COALESCE(?, '') | |
| 97 | + `, authorDID, articleURL, quote, note) | |
| 98 | + return err | |
| 99 | +} | |
| 100 | + | |
| 87 | 101 | func (s *ArticleStore) AnnotationExists(ctx context.Context, uri string) (bool, error) { |
| 88 | 102 | var exists int |
| 89 | 103 | err := s.db.QueryRowContext(ctx, `SELECT 1 FROM articles.annotations WHERE uri = ?`, uri).Scan(&exists) |
| @@ -84,6 +84,20 @@ func (s *ArticleStore) DeleteAnnotation(ctx context.Context, uri string) error { | |||
| 84 | return err | 84 | return err |
| 85 | } | 85 | } |
| 86 | 86 | ||
| 87 | +// DeleteAnnotationsByContent removes every annotation a user has for a given | ||
| 88 | +// article + quote + note. A glean annotation and its at.margin.note mirror share | ||
| 89 | +// this content, so deleting by content clears both rows even though they have | ||
| 90 | +// different URIs. | ||
| 91 | +func (s *ArticleStore) DeleteAnnotationsByContent(ctx context.Context, authorDID, articleURL, quote, note string) error { | ||
| 92 | + _, err := s.db.ExecContext(ctx, ` | ||
| 93 | + DELETE FROM articles.annotations | ||
| 94 | + WHERE author_did = ? AND article_url = ? | ||
| 95 | + AND COALESCE(quote, '') = COALESCE(?, '') | ||
| 96 | + AND COALESCE(note, '') = COALESCE(?, '') | ||
| 97 | + `, authorDID, articleURL, quote, note) | ||
| 98 | + return err | ||
| 99 | +} | ||
| 100 | + | ||
| 87 | func (s *ArticleStore) AnnotationExists(ctx context.Context, uri string) (bool, error) { | 101 | func (s *ArticleStore) AnnotationExists(ctx context.Context, uri string) (bool, error) { |
| 88 | var exists int | 102 | var exists int |
| 89 | err := s.db.QueryRowContext(ctx, `SELECT 1 FROM articles.annotations WHERE uri = ?`, uri).Scan(&exists) | 103 | err := s.db.QueryRowContext(ctx, `SELECT 1 FROM articles.annotations WHERE uri = ?`, uri).Scan(&exists) |
modified
internal/server/annotations_handler.go +9 -1 | @@ -196,10 +196,18 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 196 | 196 | return |
| 197 | 197 | } |
| 198 | 198 | } |
| 199 | + | |
| 200 | + // The annotation is mirrored to at.margin.note on creation; delete the | |
| 201 | + // mirror too, otherwise the next sync resurrects it as a margin note. | |
| 202 | + if mirrorErr := atproto.DeleteMirroredMarginNotes(ctx, client, user.DID, annotation.ArticleURL, annotation.Quote.String, annotation.Note.String); mirrorErr != nil { | |
| 203 | + s.logger.Error("failed to delete mirrored margin note", "error", mirrorErr) | |
| 204 | + } | |
| 199 | 205 | } |
| 200 | 206 | } |
| 201 | 207 | |
| 202 | - if err := s.dbs.Articles.DeleteAnnotation(ctx, annotation.URI); err != nil { | |
| 208 | + // Delete by content so any duplicate row created by the mirror (different URI, | |
| 209 | + // identical content) is removed alongside the canonical annotation. | |
| 210 | + if err := s.dbs.Articles.DeleteAnnotationsByContent(ctx, user.DID, annotation.ArticleURL, annotation.Quote.String, annotation.Note.String); err != nil { | |
| 203 | 211 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 204 | 212 | return |
| 205 | 213 | } |
| @@ -196,10 +196,18 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) | |||
| 196 | return | 196 | return |
| 197 | } | 197 | } |
| 198 | } | 198 | } |
| 199 | + | ||
| 200 | + // The annotation is mirrored to at.margin.note on creation; delete the | ||
| 201 | + // mirror too, otherwise the next sync resurrects it as a margin note. | ||
| 202 | + if mirrorErr := atproto.DeleteMirroredMarginNotes(ctx, client, user.DID, annotation.ArticleURL, annotation.Quote.String, annotation.Note.String); mirrorErr != nil { | ||
| 203 | + s.logger.Error("failed to delete mirrored margin note", "error", mirrorErr) | ||
| 204 | + } | ||
| 199 | } | 205 | } |
| 200 | } | 206 | } |
| 201 | 207 | ||
| 202 | - if err := s.dbs.Articles.DeleteAnnotation(ctx, annotation.URI); err != nil { | 208 | + // Delete by content so any duplicate row created by the mirror (different URI, |
| 209 | + // identical content) is removed alongside the canonical annotation. | ||
| 210 | + if err := s.dbs.Articles.DeleteAnnotationsByContent(ctx, user.DID, annotation.ArticleURL, annotation.Quote.String, annotation.Note.String); err != nil { | ||
| 203 | http.Error(w, err.Error(), http.StatusInternalServerError) | 211 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 204 | return | 212 | return |
| 205 | } | 213 | } |