refactor(annotations): one LWW decision core + one tombstone gate + typed queue decoders

The consolidation half of H3 — shared LOGIC with thin per-type remains:

- lwwDecision: the three compareIncoming* functions were the same
  skeleton (client timestamp -> strictly-newer wins; no timestamp ->
  any content difference wins) drifting apart only in per-type
  equality and the schema-driven fallback timestamp (bookmarks carry
  no updated_at). The decision now lives in one function; each compare
  keeps only its equality expression.
- idTargetOwnership + tombstoneWins: the id-targeted resolution blocks
  (HighlightID/BookmarkID/NoteID) shared the ownership check and the
  deleted-row gate — now one gate each instead of three copies of the
  resurrect semantics.
- queue.go: syncNote/syncHighlight/syncBookmark decoded device payloads
  through three 10-key if-type-assertion chains; typed map accessors
  (mapStr/mapF64/mapI32/mapDeviceSyncData) replace them — same
  missing-key-is-zero semantics, declarative field lists. The progress
  decoder is untouched (H1 territory, separately tested).

Per-type remains are the things that genuinely cannot merge: request
structs, dedup triples, the per-table sqlc param builders, and the
KOReader/Kobo payload adapters. Pins, H1/H3 tests, both unit suites
green.
This commit is contained in:
John O'Keefe committed 2026-10-17 15:56:24 -04:00
1 parent 7d698ea0d8
commit 11609d13d1
2 files changed
+120 -161

No files matched your search

+59 -62
View File
@@ -95,6 +95,22 @@ type SaveHighlightResult struct {
Conflict bool
}
// idTargetOwnership verifies an id-targeted edit's ownership: the URL row
// must belong to the requesting user and media item.
func idTargetOwnership(rowUser, rowItem, reqUser, reqItem pgtype.UUID, kind string, id pgtype.UUID) error {
if rowUser != reqUser || rowItem != reqItem {
return fmt.Errorf("%s %s belongs to another user or media item", kind, id)
}
return nil
}
// tombstoneWins is the shared tombstone gate for id-targeted edits: a
// deleted row stays deleted unless the edit is newer than the tombstone
// (a deliberate re-create — the LWW update then resurrects the row).
func tombstoneWins(deleted bool, deletedAt, lastModifiedAt pgtype.Timestamptz, modifiedAt time.Time) bool {
return deleted && !incomingNewerThanTombstone(modifiedAt, deletedAt, lastModifiedAt)
}
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
dedupKey := req.DedupKey
@@ -108,18 +124,14 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight
return nil, fmt.Errorf("query highlight by id: %w", idErr)
}
if idErr == nil {
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
return nil, fmt.Errorf("highlight %s belongs to another user or media item", req.HighlightID)
if err := idTargetOwnership(byID.UserID, byID.MediaItemID, req.UserID, req.MediaItemID, "highlight", req.HighlightID); err != nil {
return nil, err
}
if dedupKey == "" {
dedupKey = byID.DedupKey.String
}
if byID.Deleted.Bool {
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
return &SaveHighlightResult{Highlight: byID, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect
// via the LWW update (which clears deleted/deleted_at).
if tombstoneWins(byID.Deleted.Bool, byID.DeletedAt, byID.LastModifiedAt, req.ModifiedAt) {
return &SaveHighlightResult{Highlight: byID, Outcome: SaveOutcomeDeleted}, nil
}
return s.applyLWW(ctx, req, byID, dedupKey)
}
@@ -275,22 +287,14 @@ func (s *AnnotationService) applyLWW(
}
func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing database.MediaHighlights) (incomingNewer bool, contentChanged bool) {
if req.ModifiedAt.IsZero() {
contentSame := strings.EqualFold(req.SelectionText, existing.SelectionText) &&
textEq(req.Color, existing.Color) &&
textEq(req.NoteText, existing.NoteText) &&
floatEq(req.PercentageStart, existing.PercentageStart) &&
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
if !existingMod.Valid {
existingMod = existing.UpdatedAt
}
return req.ModifiedAt.After(existingMod.Time), true
contentSame := strings.EqualFold(req.SelectionText, existing.SelectionText) &&
textEq(req.Color, existing.Color) &&
textEq(req.NoteText, existing.NoteText) &&
floatEq(req.PercentageStart, existing.PercentageStart) &&
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
return lwwDecision(req.ModifiedAt, existing.LastModifiedAt, existing.UpdatedAt, contentSame)
}
// locatorRefresher reports whether an incoming locator leaves the stored
@@ -551,8 +555,8 @@ func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (
return nil, fmt.Errorf("query note by id: %w", idErr)
}
if idErr == nil {
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
return nil, fmt.Errorf("note %s belongs to another user or media item", req.NoteID)
if err := idTargetOwnership(byID.UserID, byID.MediaItemID, req.UserID, req.MediaItemID, "note", req.NoteID); err != nil {
return nil, err
}
if dedupKey == "" {
dedupKey = byID.DedupKey.String
@@ -575,12 +579,8 @@ func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (
if req.ParagraphReference == 0 {
req.ParagraphReference = byID.ParagraphReference.Int32
}
if byID.Deleted.Bool {
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
return &SaveNoteResult{Note: byID, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect
// via the LWW update (which clears deleted/deleted_at).
if tombstoneWins(byID.Deleted.Bool, byID.DeletedAt, byID.LastModifiedAt, req.ModifiedAt) {
return &SaveNoteResult{Note: byID, Outcome: SaveOutcomeDeleted}, nil
}
return s.applyNoteLWW(ctx, req, byID, dedupKey)
}
@@ -692,20 +692,28 @@ func (s *AnnotationService) applyNoteLWW(ctx context.Context, req SaveNoteReques
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}
func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing database.MediaNotes) (incomingNewer bool, contentChanged bool) {
if req.ModifiedAt.IsZero() {
contentSame := strings.EqualFold(req.Content, existing.Content) &&
textEq(req.Position, existing.Position)
// lwwDecision is the shared LWW decision core for all three annotation
// stacks: with a client timestamp, strictly-newer wins (and content is
// treated as changed — the update re-lands everything); without one, any
// content difference is "newer" and identical content resolves to skip.
// The fallback timestamp is schema-driven per stack (rows with an
// updated_at fall back to it; bookmarks only carry created_at).
func lwwDecision(modifiedAt time.Time, existingLastMod, existingFallback pgtype.Timestamptz, contentSame bool) (incomingNewer bool, contentChanged bool) {
if modifiedAt.IsZero() {
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
existingMod := existingLastMod
if !existingMod.Valid {
existingMod = existing.UpdatedAt
existingMod = existingFallback
}
if !existingMod.Valid {
return true, true
}
return req.ModifiedAt.After(existingMod.Time), true
return modifiedAt.After(existingMod.Time), true
}
// compareIncomingNote is compareIncoming for the note stack.
func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing database.MediaNotes) (incomingNewer bool, contentChanged bool) {
contentSame := strings.EqualFold(req.Content, existing.Content) &&
textEq(req.Position, existing.Position)
return lwwDecision(req.ModifiedAt, existing.LastModifiedAt, existing.UpdatedAt, contentSame)
}
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id, userID pgtype.UUID) error {
@@ -764,8 +772,8 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
return nil, fmt.Errorf("query bookmark by id: %w", idErr)
}
if idErr == nil {
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
return nil, fmt.Errorf("bookmark %s belongs to another user or media item", req.BookmarkID)
if err := idTargetOwnership(byID.UserID, byID.MediaItemID, req.UserID, req.MediaItemID, "bookmark", req.BookmarkID); err != nil {
return nil, err
}
if dedupKey == "" {
dedupKey = byID.DedupKey.String
@@ -788,12 +796,8 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
if req.ChapterReference == 0 {
req.ChapterReference = byID.ChapterReference.Int32
}
if byID.Deleted.Bool {
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
return &SaveBookmarkResult{Bookmark: byID, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect
// via the LWW update (which clears deleted/deleted_at).
if tombstoneWins(byID.Deleted.Bool, byID.DeletedAt, byID.LastModifiedAt, req.ModifiedAt) {
return &SaveBookmarkResult{Bookmark: byID, Outcome: SaveOutcomeDeleted}, nil
}
return s.applyBookmarkLWW(ctx, req, byID, dedupKey)
}
@@ -923,18 +927,11 @@ 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) &&
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
locatorRefresher(req.Position, existing.Position)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
if !existingMod.Valid {
existingMod = existing.CreatedAt
}
return req.ModifiedAt.After(existingMod.Time), true
contentSame := strings.EqualFold(req.Title, existing.Title) &&
textEq(req.Notes, existing.Notes) &&
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
locatorRefresher(req.Position, existing.Position)
return lwwDecision(req.ModifiedAt, existing.LastModifiedAt, existing.CreatedAt, contentSame)
}
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, userID, bookmarkID pgtype.UUID, source string) error {