From d4c52e9a6a2b5e034af258f2c8597c2e1d5b1429 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 22 Aug 2026 13:16:36 -0400 Subject: [PATCH] feat(sync): restore/purge service methods + bookmark tombstone by dedup key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RestoreAnnotationByID and PurgeAnnotationByID dispatch on annotation kind (highlight/note/bookmark) to the new queries, broadcasting an annotation update on restore so connected web sessions refresh. Both report whether a row actually changed. TombstoneBookmarkByDedupKey mirrors the existing TombstoneHighlight for bookmarks: devices report deletions by dedup key (they have no row IDs), and until now only highlights had a key-based tombstone path — device bookmark deletions had nowhere to land. ValidAnnotationKind centralizes the kind check the HTTP handlers share. --- internal/sync/annotations.go | 93 ++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go index 4f55373..3746ff4 100644 --- a/internal/sync/annotations.go +++ b/internal/sync/annotations.go @@ -268,6 +268,99 @@ func (s *AnnotationService) TombstoneHighlightByID( return nil } +// TombstoneBookmarkByDedupKey soft-deletes a bookmark by its dedup key — the +// device-sync counterpart of TombstoneHighlight. Devices report deletions by +// dedup key (they have no row IDs), so this keeps bookmark delete propagation +// symmetric with highlights. +func (s *AnnotationService) TombstoneBookmarkByDedupKey( + ctx context.Context, + userID, mediaItemID pgtype.UUID, + dedupKey string, + source string, +) error { + if dedupKey == "" { + return nil + } + err := s.db.TombstoneMediaBookmarkByDedupKey(ctx, database.TombstoneMediaBookmarkByDedupKeyParams{ + UserID: userID, + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return fmt.Errorf("tombstone bookmark: %w", err) + } + s.broadcast(pgtype.UUID{}, userID, mediaItemID, "bookmark_delete", source) + return nil +} + +// ValidAnnotationKind reports whether kind is one of the annotation types +// accepted by the history restore/purge endpoints. +func ValidAnnotationKind(kind string) bool { + return kind == "highlight" || kind == "note" || kind == "bookmark" +} + +// RestoreAnnotationByID clears the tombstone on a deleted annotation, +// returning it to the active set. The row itself was never removed, so +// restoration is lossless. Returns false when no matching deleted annotation +// exists (wrong owner, wrong book, or not actually deleted). +func (s *AnnotationService) RestoreAnnotationByID( + ctx context.Context, + kind string, + userID, mediaItemID, annotationID pgtype.UUID, +) (bool, error) { + var rows int64 + var err error + switch kind { + case "highlight": + rows, err = s.db.RestoreMediaHighlightByID(ctx, database.RestoreMediaHighlightByIDParams{ + ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) + case "note": + rows, err = s.db.RestoreMediaNoteByID(ctx, database.RestoreMediaNoteByIDParams{ + ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) + case "bookmark": + rows, err = s.db.RestoreMediaBookmarkByID(ctx, database.RestoreMediaBookmarkByIDParams{ + ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) + default: + return false, fmt.Errorf("unknown annotation kind: %s", kind) + } + if err != nil { + return false, fmt.Errorf("restore %s: %w", kind, err) + } + if rows > 0 { + s.broadcast(annotationID, userID, mediaItemID, kind, "web") + } + return rows > 0, nil +} + +// PurgeAnnotationByID permanently deletes an already-tombstoned annotation +// from the history. Unlike a tombstone this is irreversible; the TTL-driven +// maintenance sweep does the same thing to old tombstones eventually. +func (s *AnnotationService) PurgeAnnotationByID( + ctx context.Context, + kind string, + userID, mediaItemID, annotationID pgtype.UUID, +) (bool, error) { + var rows int64 + var err error + switch kind { + case "highlight": + rows, err = s.db.PurgeMediaHighlightByID(ctx, database.PurgeMediaHighlightByIDParams{ + ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) + case "note": + rows, err = s.db.PurgeMediaNoteByID(ctx, database.PurgeMediaNoteByIDParams{ + ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) + case "bookmark": + rows, err = s.db.PurgeMediaBookmarkByID(ctx, database.PurgeMediaBookmarkByIDParams{ + ID: annotationID, UserID: userID, MediaItemID: mediaItemID}) + default: + return false, fmt.Errorf("unknown annotation kind: %s", kind) + } + if err != nil { + return false, fmt.Errorf("purge %s: %w", kind, err) + } + return rows > 0, nil +} + func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error { cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true} if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {