fix(sync): let content-equal echoes refresh drifted locators

The LWW skip path compared only annotation content (text, color, note,
percentages) — locator columns were not part of 'changed'. A device echo
with identical content therefore resolved to skip, and the freshly
re-derived canonical CFIs were discarded in the same request that
computed them: a highlight whose stored start anchor had been corrupted
by the old percentage-exact bug could never heal, because every
subsequent echo carried the same text and was skipped before the
locator columns were written. Observed live: a push converted the
cross-block highlight's anchors correctly (structural, heading to
paragraph) yet the row kept its garbage start CFI and the highlight
stayed unpaintable on the web.

Echo saves now treat a non-empty incoming locator that differs from the
stored one as a change (locatorRefresher): empty locators still coalesce
(no drift), and once healed the echo produces identical CFIs, so the
steady state remains skip — no write churn. Highlights check
epubcfi_start/end; bookmarks check cfi_position/position.

applyBookmarkLWW also gains the empty-locator coalescing the highlight
path already had: web bookmark edits carry no device locators, and a
title/note edit must not wipe the stored device-native position.
This commit is contained in:
2026-09-10 08:10:42 -04:00
parent b6f507b9e5
commit ae87c0cd6a
2 changed files with 115 additions and 4 deletions
+36 -4
View File
@@ -279,7 +279,9 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
textEq(req.Color, existing.Color) &&
textEq(req.NoteText, existing.NoteText) &&
floatEq(req.PercentageStart, existing.PercentageStart) &&
floatEq(req.PercentageEnd, existing.PercentageEnd)
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
return !contentSame, !contentSame
}
@@ -290,6 +292,22 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
return req.ModifiedAt.After(existingMod.Time), true
}
// locatorRefresher reports whether an incoming locator leaves the stored
// one untouched: either the push carried none (empty coalesces to the
// stored value in applyLWW) or it matches what is stored. A non-empty
// incoming locator that DIFFERS is a deliberate refresh: device echoes
// re-derive their canonical CFIs on every push, and an improvement (a
// converter fix re-landing a corrupted anchor, drift repair) must reach
// the row even when the annotation content is otherwise identical —
// without this, content-equal echoes resolve to "skip" and a bad stored
// locator can never heal.
func locatorRefresher(incoming string, existing pgtype.Text) bool {
if incoming == "" {
return true
}
return textEq(incoming, existing)
}
func (s *AnnotationService) TombstoneHighlight(
ctx context.Context,
userID, mediaItemID pgtype.UUID,
@@ -744,13 +762,25 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
}
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
// Web saves carry no device locators: keep the stored ones so a web
// title/note edit never wipes the device-native position (mirrors the
// highlight path's coalescing).
cfiPosition := req.CFIPosition
if cfiPosition == "" {
cfiPosition = existing.CfiPosition.String
}
position := req.Position
if position == "" {
position = existing.Position.String
}
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
ID: existing.ID,
PageNumber: pgInt4(req.PageNumber),
ChapterNumber: pgInt4(req.ChapterNumber),
CfiPosition: pgText(req.CFIPosition),
CfiPosition: pgText(cfiPosition),
Title: req.Title,
Position: pgText(req.Position),
Position: pgText(position),
Notes: pgText(req.Notes),
PercentageLocation: pgFloat8(req.PercentageLoc),
EpubcfiLocation: pgText(req.EpubcfiLocation),
@@ -775,7 +805,9 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
if req.ModifiedAt.IsZero() {
contentSame := strings.EqualFold(req.Title, existing.Title) &&
textEq(req.Notes, existing.Notes)
textEq(req.Notes, existing.Notes) &&
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
locatorRefresher(req.Position, existing.Position)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt