Files
bookhoard/internal/sync/annotations.go
T
John O'Keefe 7d698ea0d8 fix(annotations): bookmark and note PUTs land through the sync service (LWW by id)
H3 census bugs #2 and #3: both web PUTs used plain queries that
bypassed the sync-aware layer entirely — no LWW, no source bookkeeping,
and locator fields the client couldn't even send. The highlight PUT was
the only one already routed (via HighlightID); the other two now mirror
it:

- SaveBookmarkRequest gains BookmarkID, SaveNoteRequest gains NoteID:
  id-targeted resolution reads the exact row, verifies ownership,
  LWWs under its stored dedup key, and resurrects tombstones when the
  edit is newer (same doctrine and comment structure as the highlight
  path).
- Fields the PUT omits are coalesced from the stored row before the
  full-column sync update — the legacy web PUT sends only
  title/notes/position, and zeroing cfi/percentage/page on every edit
  would have been data loss. Verified both directions by
  TestH3_BookmarkPUTPreservesLocators (omitted survives, explicit
  lands).
- The bookmark PUT's wire DTO gains the create endpoint's locator keys
  as optional fields (documented additive request keys in
  contract_pins.md); the note PUT keeps its content+position wire.

UpdateMediaBookmark/UpdateMediaNote plain queries are now uncalled
(the generated queries stay until the H4 query sweep).
2026-10-17 15:53:12 -04:00

1159 lines
39 KiB
Go

package sync
import (
"bookhoard/internal/database"
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// TombstoneTTL is the fallback retention for soft-deleted annotations when no
// settings registry is wired (e.g. in tests). It matches the historical value.
const TombstoneTTL = 30 * 24 * time.Hour
type AnnotationService struct {
db *database.Queries
connMgr *ConnectionManager
settings *database.SettingsRegistry
}
func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
return &AnnotationService{db: db, connMgr: connMgr}
}
// SetSettings wires the tunable settings registry. When wired, the tombstone
// TTL is read live from the DB; otherwise the package const TombstoneTTL is
// used.
func (s *AnnotationService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
// tombstoneTTL returns the active tombstone retention window.
func (s *AnnotationService) tombstoneTTL() time.Duration {
if s.settings != nil {
return s.settings.TombstoneTTL()
}
return TombstoneTTL
}
// ActiveTombstoneTTL exposes the configured tombstone retention window for
// callers outside the sync package (e.g. kobo/koreader handlers) that need to
// compute cutoffs consistently with the service.
func (s *AnnotationService) ActiveTombstoneTTL() time.Duration { return s.tombstoneTTL() }
type SaveOutcome string
const (
SaveOutcomeCreated SaveOutcome = "created"
SaveOutcomeUpdated SaveOutcome = "updated"
SaveOutcomeSkipped SaveOutcome = "skipped"
SaveOutcomeDeleted SaveOutcome = "deleted"
)
type SaveHighlightRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
// HighlightID, when valid, targets that exact row (web PUTs edit by
// id): the save LWWs against it directly under its stored dedup key.
// The computed key depends on fields that legitimately change — the
// stored CFI drifts range→point shape after device echoes, and the
// user can edit the selection text — so a key-based upsert would mint
// a duplicate beside the very row being edited.
HighlightID pgtype.UUID
PercentageStart float64
PercentageEnd float64
EpubcfiStart string
EpubcfiEnd string
ChapterReference int32
Source string
ModifiedAt time.Time
DeviceSyncData json.RawMessage
// DedupKey overrides the computed key when the client echoes back an
// annotation it received from us (device echoes carry device-native
// locators, so the computed key would never match the original row and
// every pull→push cycle would mint a duplicate).
DedupKey string
}
type SaveHighlightResult struct {
Highlight database.MediaHighlights
Outcome SaveOutcome
Conflict bool
}
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
dedupKey := req.DedupKey
// Web edits arrive with the row id from the URL: resolve by id first
// and LWW against that row under its stored key. Content-derived keys
// are for lookups by identity (device pushes carry no row ids); an
// edit must never re-derive identity from (possibly edited) content.
if req.HighlightID.Valid {
byID, idErr := s.db.GetMediaHighlight(ctx, req.HighlightID)
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
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 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).
}
return s.applyLWW(ctx, req, byID, dedupKey)
}
// No row with that id: fall through to identity-based resolution.
}
if dedupKey == "" {
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
}
existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
UserID: req.UserID,
MediaItemID: req.MediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("query existing highlight: %w", err)
}
if errors.Is(err, pgx.ErrNoRows) {
return s.createHighlight(ctx, req, dedupKey)
}
if existing.Deleted.Bool {
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect via the
// LWW update (which clears deleted/deleted_at).
return s.applyLWW(ctx, req, existing, dedupKey)
}
return s.applyLWW(ctx, req, existing, dedupKey)
}
func (s *AnnotationService) createHighlight(
ctx context.Context,
req SaveHighlightRequest,
dedupKey string,
) (*SaveHighlightResult, error) {
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
SelectionText: req.SelectionText,
StartPosition: pgText(req.StartPosition),
EndPosition: pgText(req.EndPosition),
Color: pgText(req.Color),
NoteText: pgText(req.NoteText),
PercentageStart: pgFloat8(req.PercentageStart),
PercentageEnd: pgFloat8(req.PercentageEnd),
EpubcfiStart: pgText(req.EpubcfiStart),
EpubcfiEnd: pgText(req.EpubcfiEnd),
ChapterReference: pgInt4(req.ChapterReference),
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
})
if err != nil {
return nil, fmt.Errorf("create highlight: %w", err)
}
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeCreated}, nil
}
func (s *AnnotationService) applyLWW(
ctx context.Context,
req SaveHighlightRequest,
existing database.MediaHighlights,
dedupKey string,
) (*SaveHighlightResult, error) {
incomingNewer, contentChanged := s.compareIncoming(req, existing)
if !incomingNewer && !contentChanged {
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "existing")
}
return &SaveHighlightResult{
Highlight: existing,
Outcome: SaveOutcomeSkipped,
Conflict: conflict,
}, nil
}
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
// Web edits carry no device locators (the web reader never had a CRE
// xpointer) and may carry no CFI either: keep the stored ones so
// device-native serve-back and round-trip identity survive a web-side
// note/color edit instead of being wiped to empty.
startPosition := req.StartPosition
if startPosition == "" {
startPosition = existing.StartPosition.String
}
endPosition := req.EndPosition
if endPosition == "" {
endPosition = existing.EndPosition.String
}
epubcfiStart := req.EpubcfiStart
if epubcfiStart == "" {
epubcfiStart = existing.EpubcfiStart.String
}
epubcfiEnd := req.EpubcfiEnd
if epubcfiEnd == "" {
epubcfiEnd = existing.EpubcfiEnd.String
}
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
ID: existing.ID,
SelectionText: req.SelectionText,
StartPosition: pgText(startPosition),
EndPosition: pgText(endPosition),
Color: pgText(req.Color),
NoteText: pgText(req.NoteText),
PercentageStart: pgFloat8(req.PercentageStart),
PercentageEnd: pgFloat8(req.PercentageEnd),
EpubcfiStart: pgText(epubcfiStart),
EpubcfiEnd: pgText(epubcfiEnd),
ChapterReference: pgInt4(req.ChapterReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
UserID: req.UserID,
})
if err != nil {
return nil, fmt.Errorf("update highlight: %w", err)
}
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "incoming")
}
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}
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
}
// 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,
dedupKey string,
source string,
) error {
err := s.db.TombstoneMediaHighlightByDedupKey(ctx, database.TombstoneMediaHighlightByDedupKeyParams{
UserID: userID,
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return fmt.Errorf("tombstone highlight: %w", err)
}
s.broadcast(pgtype.UUID{}, userID, mediaItemID, "highlight_delete", source)
return nil
}
func (s *AnnotationService) TombstoneHighlightByID(
ctx context.Context,
userID, highlightID pgtype.UUID,
source string,
) error {
h, err := s.db.GetMediaHighlight(ctx, highlightID)
if err != nil {
return fmt.Errorf("get highlight for tombstone: %w", err)
}
// Ownership predicate: the caller must own the row. A mismatched user
// gets a not-found-style error, never the row's existence.
if h.UserID != userID {
return fmt.Errorf("tombstone highlight by ID: %w", pgx.ErrNoRows)
}
err = s.db.TombstoneMediaHighlightByID(ctx, database.TombstoneMediaHighlightByIDParams{
ID: highlightID,
UserID: userID,
})
if err != nil {
return fmt.Errorf("tombstone highlight by ID: %w", err)
}
s.broadcast(pgtype.UUID{}, h.UserID, h.MediaItemID, "highlight_delete", source)
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 {
return fmt.Errorf("purge highlight tombstones: %w", err)
}
if err := s.db.PurgeExpiredNoteTombstones(ctx, cutoff); err != nil {
return fmt.Errorf("purge note tombstones: %w", err)
}
if err := s.db.PurgeExpiredBookmarkTombstones(ctx, cutoff); err != nil {
return fmt.Errorf("purge bookmark tombstones: %w", err)
}
return nil
}
// StartDailyMaintenance launches a single background goroutine that runs all
// periodic cleanup tasks once every 24 hours: expired annotation tombstones,
// expired/revoked refresh tokens (retention follows the configured session
// duration), and expired OPDS tokens. Each task is independent; a failure in
// one is logged and does not skip the others. The returned CancelFunc stops the
// goroutine and the underlying ticker; it must be invoked on shutdown.
func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc {
ticker := time.NewTicker(24 * time.Hour)
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
ticker.Stop()
return
case <-ticker.C:
s.runDailyMaintenance(ctx)
}
}
}()
return cancel
}
// runDailyMaintenance executes every periodic cleanup task. Tasks run
// sequentially under the single daily-tick goroutine so there is no added
// concurrency. All three queries only delete rows that are already unusable
// (expired or revoked), so this never logs out active sessions.
func (s *AnnotationService) runDailyMaintenance(ctx context.Context) {
if err := s.PurgeExpiredTombstones(ctx); err != nil {
log.Printf("maintenance: tombstone purge failed: %v", err)
}
if err := s.db.CleanupExpiredOpdsTokens(ctx); err != nil {
log.Printf("maintenance: OPDS token purge failed: %v", err)
}
// Refresh-token retention follows the configured session duration; re-read
// on every tick so live settings changes are honored. Guarded so unwired
// test paths simply skip cleanup (production always wires the registry).
if s.settings != nil {
retention := s.settings.SessionDuration().Seconds()
if err := s.db.CleanupExpiredRefreshTokens(ctx, retention); err != nil {
log.Printf("maintenance: refresh token purge failed: %v", err)
}
}
}
type SaveNoteRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
Content string
Position string
PercentageLocation float64
CharacterStart int32
CharacterEnd int32
EpubcfiLocation string
ChapterReference int32
ParagraphReference int32
Source string
ModifiedAt time.Time
DeviceSyncData []byte
DedupKey string // overrides the computed key for device echoes
// NoteID, when valid, targets that exact row (web PUTs edit by id):
// the save LWWs against it under its stored dedup key, mirroring
// SaveHighlightRequest.HighlightID.
NoteID pgtype.UUID
}
type SaveNoteResult struct {
Note database.MediaNotes
Outcome SaveOutcome
Conflict bool
}
func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (*SaveNoteResult, error) {
if !req.UserID.Valid || !req.MediaItemID.Valid {
return nil, errors.New("invalid user_id or media_item_id")
}
dedupKey := req.DedupKey
// Web edits arrive with the row id from the URL: resolve by id and LWW
// against that row under its stored key (same doctrine as
// SaveHighlight's HighlightID path). Locator columns the note PUT
// does not carry are coalesced from the stored row so the full-column
// sync update cannot zero them.
if req.NoteID.Valid {
byID, idErr := s.db.GetMediaNote(ctx, req.NoteID)
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
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 dedupKey == "" {
dedupKey = byID.DedupKey.String
}
if req.EpubcfiLocation == "" {
req.EpubcfiLocation = byID.EpubcfiLocation.String
}
if req.PercentageLocation == 0 {
req.PercentageLocation = byID.PercentageLocation.Float64
}
if req.CharacterStart == 0 {
req.CharacterStart = byID.CharacterStart.Int32
}
if req.CharacterEnd == 0 {
req.CharacterEnd = byID.CharacterEnd.Int32
}
if req.ChapterReference == 0 {
req.ChapterReference = byID.ChapterReference.Int32
}
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).
}
return s.applyNoteLWW(ctx, req, byID, dedupKey)
}
// No row with that id: fall through to identity-based resolution.
}
if dedupKey == "" {
dedupKey = ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
}
existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
UserID: req.UserID,
MediaItemID: req.MediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("get note by dedup key: %w", err)
}
return s.createNote(ctx, req, dedupKey)
}
if existing.Deleted.Valid && existing.Deleted.Bool {
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect.
return s.applyNoteLWW(ctx, req, existing, dedupKey)
}
return s.applyNoteLWW(ctx, req, existing, dedupKey)
}
func (s *AnnotationService) createNote(ctx context.Context, req SaveNoteRequest, dedupKey string) (*SaveNoteResult, error) {
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
note, err := s.db.CreateMediaNoteFull(ctx, database.CreateMediaNoteFullParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
Content: req.Content,
Position: pgText(req.Position),
PercentageLocation: pgFloat8(req.PercentageLocation),
CharacterStart: pgInt4(req.CharacterStart),
CharacterEnd: pgInt4(req.CharacterEnd),
EpubcfiLocation: pgText(req.EpubcfiLocation),
ChapterReference: pgInt4(req.ChapterReference),
ParagraphReference: pgInt4(req.ParagraphReference),
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: req.DeviceSyncData,
})
if err != nil {
return nil, fmt.Errorf("create note: %w", err)
}
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeCreated}, nil
}
func (s *AnnotationService) applyNoteLWW(ctx context.Context, req SaveNoteRequest, existing database.MediaNotes, dedupKey string) (*SaveNoteResult, error) {
incomingNewer, contentChanged := s.compareIncomingNote(req, existing)
if !incomingNewer && !contentChanged {
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "existing")
}
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
}
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
note, err := s.db.UpdateMediaNoteForSync(ctx, database.UpdateMediaNoteForSyncParams{
ID: existing.ID,
UserID: req.UserID,
Content: req.Content,
Position: pgText(req.Position),
PercentageLocation: pgFloat8(req.PercentageLocation),
CharacterStart: pgInt4(req.CharacterStart),
CharacterEnd: pgInt4(req.CharacterEnd),
EpubcfiLocation: pgText(req.EpubcfiLocation),
ChapterReference: pgInt4(req.ChapterReference),
ParagraphReference: pgInt4(req.ParagraphReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
})
if err != nil {
return nil, fmt.Errorf("update note: %w", err)
}
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "incoming")
}
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
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)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
if !existingMod.Valid {
existingMod = existing.UpdatedAt
}
if !existingMod.Valid {
return true, true
}
return req.ModifiedAt.After(existingMod.Time), true
}
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id, userID pgtype.UUID) error {
return s.db.TombstoneMediaNoteByID(ctx, database.TombstoneMediaNoteByIDParams{
ID: id,
UserID: userID,
})
}
type SaveBookmarkRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
Title string
Position string
Notes string
PageNumber int32
ChapterNumber int32
CFIPosition string
PercentageLoc float64
EpubcfiLocation string
ChapterReference int32
Source string
ModifiedAt time.Time
DeviceSyncData json.RawMessage
// OriginSource records the client that created the bookmark (set once
// at insert; unlike Source it is not updated by later writers).
OriginSource string
// DedupKey overrides the computed key for device echoes (see
// SaveHighlightRequest).
DedupKey string
// BookmarkID, when valid, targets that exact row (web PUTs edit by
// id): the save LWWs against it under its stored dedup key, mirroring
// SaveHighlightRequest.HighlightID — an edit must not re-derive
// identity from (possibly edited) content.
BookmarkID pgtype.UUID
}
type SaveBookmarkResult struct {
Bookmark database.MediaBookmarks
Outcome SaveOutcome
Conflict bool
}
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
dedupKey := req.DedupKey
// Web edits arrive with the row id from the URL: resolve by id and LWW
// against that row under its stored key (same doctrine as
// SaveHighlight's HighlightID path). Fields the edit does not carry
// are coalesced from the stored row — the web PUT predates the
// locator columns and omits them; zeroing them on update would be
// data loss.
if req.BookmarkID.Valid {
byID, idErr := s.db.GetMediaBookmark(ctx, req.BookmarkID)
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
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 dedupKey == "" {
dedupKey = byID.DedupKey.String
}
if req.CFIPosition == "" {
req.CFIPosition = byID.CfiPosition.String
}
if req.EpubcfiLocation == "" {
req.EpubcfiLocation = byID.EpubcfiLocation.String
}
if req.PercentageLoc == 0 {
req.PercentageLoc = byID.PercentageLocation.Float64
}
if req.PageNumber == 0 {
req.PageNumber = byID.PageNumber.Int32
}
if req.ChapterNumber == 0 {
req.ChapterNumber = byID.ChapterNumber.Int32
}
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).
}
return s.applyBookmarkLWW(ctx, req, byID, dedupKey)
}
// No row with that id: fall through to identity-based resolution.
}
if dedupKey == "" {
dedupKey = ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
}
existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
UserID: req.UserID,
MediaItemID: req.MediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("query existing bookmark: %w", err)
}
if errors.Is(err, pgx.ErrNoRows) {
return s.createBookmark(ctx, req, dedupKey)
}
if existing.Deleted.Bool {
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create at the same
// location. Resurrect via the LWW update so the row keeps its id
// and origin.
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
}
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
}
func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmarkRequest, dedupKey string) (*SaveBookmarkResult, error) {
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
PageNumber: pgInt4(req.PageNumber),
ChapterNumber: pgInt4(req.ChapterNumber),
CfiPosition: pgText(req.CFIPosition),
Title: req.Title,
Position: pgText(req.Position),
Notes: pgText(req.Notes),
PercentageLocation: pgFloat8(req.PercentageLoc),
EpubcfiLocation: pgText(req.EpubcfiLocation),
ChapterReference: pgInt4(req.ChapterReference),
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
OriginSource: pgText(req.OriginSource),
})
if err != nil {
return nil, fmt.Errorf("create bookmark: %w", err)
}
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeCreated}, nil
}
func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookmarkRequest, existing database.MediaBookmarks, dedupKey string) (*SaveBookmarkResult, error) {
incomingNewer, contentChanged := s.compareIncomingBookmark(req, existing)
if !incomingNewer && !contentChanged {
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "existing")
}
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
}
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
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(cfiPosition),
Title: req.Title,
Position: pgText(position),
Notes: pgText(req.Notes),
PercentageLocation: pgFloat8(req.PercentageLoc),
EpubcfiLocation: pgText(req.EpubcfiLocation),
ChapterReference: pgInt4(req.ChapterReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
UserID: req.UserID,
})
if err != nil {
return nil, fmt.Errorf("update bookmark: %w", err)
}
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "incoming")
}
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}
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
}
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, userID, bookmarkID pgtype.UUID, source string) error {
bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
if err != nil {
return fmt.Errorf("get bookmark for tombstone: %w", err)
}
// Ownership predicate: the caller must own the row.
if bm.UserID != userID {
return fmt.Errorf("tombstone bookmark by ID: %w", pgx.ErrNoRows)
}
err = s.db.TombstoneMediaBookmarkByID(ctx, database.TombstoneMediaBookmarkByIDParams{
ID: bookmarkID,
UserID: userID,
})
if err != nil {
return fmt.Errorf("tombstone bookmark by ID: %w", err)
}
s.broadcast(pgtype.UUID{}, bm.UserID, bm.MediaItemID, "bookmark_delete", source)
return nil
}
func (s *AnnotationService) recordConflict(
ctx context.Context,
userID, mediaItemID pgtype.UUID,
conflictType, dedupKey string,
incomingSource, existingSource string,
incoming any,
existing any,
winner string,
) {
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
return
}
incomingJSON, _ := json.Marshal(incoming)
existingJSON, _ := json.Marshal(existing)
var incomingMap, existingMap map[string]interface{}
json.Unmarshal(incomingJSON, &incomingMap)
json.Unmarshal(existingJSON, &existingMap)
if incomingMap == nil {
incomingMap = map[string]interface{}{}
}
if existingMap == nil {
existingMap = map[string]interface{}{}
}
incomingMap["dedup_key"] = dedupKey
existingMap["dedup_key"] = dedupKey
conflictData, _ := json.Marshal(map[string]interface{}{
"incoming": map[string]interface{}{
"source": incomingSource,
"data": incomingMap,
},
"existing": map[string]interface{}{
"source": existingSource,
"data": existingMap,
},
})
resolutionData, _ := json.Marshal(map[string]interface{}{
"winner": winner,
"reason": "last_modified_at_wins",
})
conflict, err := s.db.CreateAutoResolvedSyncConflict(ctx, database.CreateAutoResolvedSyncConflictParams{
MediaItemID: mediaItemID,
UserID: userID,
ConflictType: conflictType,
ConflictData: conflictData,
ResolutionData: resolutionData,
})
if err != nil {
log.Printf("AnnotationService: failed to record conflict: %v", err)
return
}
var conflictIDStr string
if conflict.ID.Valid {
conflictIDStr = uuid.UUID(conflict.ID.Bytes).String()
}
s.connMgr.BroadcastConflictNotification(
uuid.UUID(mediaItemID.Bytes),
"annotation_conflict",
conflictIDStr,
)
}
func (s *AnnotationService) broadcast(
highlightID, userID, mediaItemID pgtype.UUID,
annotationType string,
source string,
) {
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
return
}
src := SourceDevice{Type: source}
s.connMgr.BroadcastAnnotationUpdate(
uuid.UUID(mediaItemID.Bytes),
annotationType,
map[string]interface{}{
"highlight_id": uuid.UUID(highlightID.Bytes),
},
src,
)
}
func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string {
normalized := normalizeText(selectionText)
posBucket := bucketPosition(epubcfiStart)
if posBucket == "" {
posBucket = bucketPosition(startPosition)
}
h := sha1.New()
h.Write([]byte(normalized))
h.Write([]byte{0})
h.Write([]byte(posBucket))
return hex.EncodeToString(h.Sum(nil))
}
// incomingNewerThanTombstone reports whether an incoming save should
// resurrect a tombstoned annotation. A save carrying a modification time
// newer than the tombstone (e.g. the user deliberately re-adding on the web,
// or a device that genuinely re-created it) wins; a save with a missing or
// older timestamp is treated as a stale replay from a client that still has
// the deleted annotation, and the tombstone stands.
func incomingNewerThanTombstone(incoming time.Time, deletedAt, lastModifiedAt pgtype.Timestamptz) bool {
if incoming.IsZero() {
return false
}
tombstone := deletedAt.Time
if lastModifiedAt.Valid && lastModifiedAt.Time.After(tombstone) {
tombstone = lastModifiedAt.Time
}
return incoming.After(tombstone)
}
func normalizeText(s string) string {
fields := strings.Fields(strings.ToLower(s))
return strings.Join(fields, " ")
}
func bucketPosition(pos string) string {
if pos == "" {
return ""
}
if strings.HasPrefix(pos, "epubcfi(") {
if idx := strings.LastIndex(pos, ":"); idx > 0 {
return pos[:idx]
}
}
if len(pos) > 50 {
return pos[:50]
}
return pos
}
func mergeDeviceSyncData(existing []byte, source string, data json.RawMessage) []byte {
if source == "" && len(data) == 0 {
return existing
}
m := make(map[string]interface{})
if len(existing) > 0 {
_ = json.Unmarshal(existing, &m)
}
if source != "" {
if len(data) > 0 {
var val interface{}
_ = json.Unmarshal(data, &val)
m[source] = val
} else {
m[source] = map[string]interface{}{"synced_at": time.Now().UTC().Format(time.RFC3339)}
}
}
result, _ := json.Marshal(m)
return result
}
func isCrossSource(incoming string, existing pgtype.Text) bool {
if incoming == "" || !existing.Valid {
return false
}
return incoming != existing.String
}
func pgText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: s, Valid: true}
}
func pgFloat8(f float64) pgtype.Float8 {
if f == 0 {
return pgtype.Float8{Valid: false}
}
return pgtype.Float8{Float64: f, Valid: true}
}
func pgInt4(i int32) pgtype.Int4 {
if i == 0 {
return pgtype.Int4{Valid: false}
}
return pgtype.Int4{Int32: i, Valid: true}
}
func textEq(a string, b pgtype.Text) bool {
if !b.Valid {
return a == ""
}
return a == b.String
}
func floatEq(a float64, b pgtype.Float8) bool {
if !b.Valid {
return a == 0
}
return math.Abs(a-b.Float64) < 0.001
}