feat(sync): restore/purge service methods + bookmark tombstone by dedup key
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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user