fix(sync): resurrect tombstoned annotations when a newer save re-creates them
Deleting a bookmark/highlight/note and then re-adding the same content at the same position (same dedup key — e.g. the reader's auto-titled 'Bookmark at X%') was silently swallowed: the save hit the tombstone branch, returned 201 with the deleted row, and the list (which filters deleted) stayed empty. Bookmarks were further blocked by the UNIQUE(media_item_id, user_id, title) slot the tombstoned row holds, and notes had no TTL escape at all. Tombstones now only block saves that predate them (stale replays from a device that still has the annotation). A save whose modification time is newer than max(deleted_at, last_modified_at) — a deliberate re-create from the web or a device — resurrects the row via the LWW update queries, which now clear deleted/deleted_at.
This commit is contained in:
@@ -100,10 +100,12 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight
|
||||
}
|
||||
|
||||
if existing.Deleted.Bool {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
|
||||
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
||||
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
return s.createHighlight(ctx, req, dedupKey)
|
||||
// Newer than the tombstone: a deliberate re-create. Resurrect via the
|
||||
// LWW update (which clears deleted/deleted_at).
|
||||
return s.applyLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
return s.applyLWW(ctx, req, existing, dedupKey)
|
||||
@@ -361,7 +363,11 @@ func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (
|
||||
}
|
||||
|
||||
if existing.Deleted.Valid && existing.Deleted.Bool {
|
||||
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
||||
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
// Newer than the tombstone: a deliberate re-create. Resurrect.
|
||||
return s.applyNoteLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
return s.applyNoteLWW(ctx, req, existing, dedupKey)
|
||||
@@ -504,10 +510,13 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
|
||||
}
|
||||
|
||||
if existing.Deleted.Bool {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
|
||||
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
return s.createBookmark(ctx, req, dedupKey)
|
||||
// Newer than the tombstone: a deliberate re-create. Resurrect via the
|
||||
// LWW update instead of INSERT (the tombstoned row still holds the
|
||||
// UNIQUE(media_item_id, user_id, title) slot).
|
||||
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
||||
@@ -717,6 +726,23 @@ func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string {
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// incomingNewerThanTombstone reports whether an incoming save should
|
||||
// resurrect a tombstoned annotation. A save carrying a modification time
|
||||
// newer than the tombstone (e.g. the user deliberately re-adding on the web,
|
||||
// or a device that genuinely re-created it) wins; a save with a missing or
|
||||
// older timestamp is treated as a stale replay from a client that still has
|
||||
// the deleted annotation, and the tombstone stands.
|
||||
func incomingNewerThanTombstone(incoming time.Time, deletedAt, lastModifiedAt pgtype.Timestamptz) bool {
|
||||
if incoming.IsZero() {
|
||||
return false
|
||||
}
|
||||
tombstone := deletedAt.Time
|
||||
if lastModifiedAt.Valid && lastModifiedAt.Time.After(tombstone) {
|
||||
tombstone = lastModifiedAt.Time
|
||||
}
|
||||
return incoming.After(tombstone)
|
||||
}
|
||||
|
||||
func normalizeText(s string) string {
|
||||
fields := strings.Fields(strings.ToLower(s))
|
||||
return strings.Join(fields, " ")
|
||||
|
||||
Reference in New Issue
Block a user