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:
2026-08-14 15:42:18 -04:00
parent 14d1a158a0
commit a962342ee0
4 changed files with 77 additions and 11 deletions
+28
View File
@@ -341,3 +341,31 @@ func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.M
PercentageEnd: pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0},
}
}
func TestIncomingNewerThanTombstone(t *testing.T) {
base := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
delAt := pgtype.Timestamptz{Time: base, Valid: true}
lastMod := pgtype.Timestamptz{Time: base.Add(-time.Minute), Valid: true}
tests := []struct {
name string
incoming time.Time
deleted pgtype.Timestamptz
lastMod pgtype.Timestamptz
want bool
}{
{"newer than tombstone resurrects", base.Add(time.Hour), delAt, lastMod, true},
{"older than tombstone is a stale replay", base.Add(-time.Hour), delAt, lastMod, false},
{"missing timestamp never resurrects", time.Time{}, delAt, lastMod, false},
{"exactly equal does not resurrect", base, delAt, lastMod, false},
{"last_modified newer than deleted_at wins", base.Add(30 * time.Minute), delAt, pgtype.Timestamptz{Time: base.Add(90 * time.Minute), Valid: true}, false},
{"invalid timestamps compare against deleted_at", base.Add(time.Hour), delAt, pgtype.Timestamptz{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := incomingNewerThanTombstone(tt.incoming, tt.deleted, tt.lastMod); got != tt.want {
t.Errorf("incomingNewerThanTombstone() = %v, want %v", got, tt.want)
}
})
}
}