feat(db): add annotation sync schema, queries, and tombstone support

Add migration columns to media_highlights, media_notes, and media_bookmarks
for cross-device annotation sync:

- dedup_key: SHA-1 of normalized selection text + bucketed position, used
  as the stable cross-device identity for annotations
- last_modified_at / last_modified_source: edit clock for LWW resolution
  and cross-source conflict detection
- deleted / deleted_at: sticky tombstone columns for delete-wins semantics
  with a 30-day TTL before rows are physically purged
- note_text on highlights: stores attached notes from KOReader entries that
  have both selected text and a user note
- Location columns on bookmarks (cfi_position, percentage_location,
  epubcfi_location, chapter_reference, paragraph_reference)
- device_sync_data JSONB on all three tables: stores per-device native
  identifiers (e.g. KOReader pos0/datetime, Kobo bookmark_id) so each
  device can locate and manipulate its own copy of an annotation

Add partial unique indexes on (user_id, media_item_id, dedup_key) where
deleted = FALSE to enforce one active annotation per dedup key.

Add tombstone purge indexes on (deleted, deleted_at) for efficient GC.

New queries:
- GetByDedupKey for all three tables (returns active or most-recent tombstone)
- CreateFull / UpdateForSync for all three tables (populate sync columns)
- TombstoneByDedupKey / TombstoneByID for all three tables
- PurgeExpired* for all three tables (GC past TTL)
- GetActiveAnnotationsForBook (filtered union of highlights + notes)
- GetTombstonedAnnotationsForBook (union of all 3 deleted within TTL)
- GetMediaBookmarks with deleted filter
- GetMediaBookmark (singular) with deleted filter
- Added deleted=FALSE filter to GetMediaHighlights, GetAnnotationsForBook
- CreateAutoResolvedSyncConflict (INSERT with resolution_status='auto_resolved')
This commit is contained in:
2026-07-29 14:48:30 -04:00
parent 78176c57a5
commit 2a15effc3e
6 changed files with 1452 additions and 54 deletions
+258 -5
View File
@@ -696,7 +696,7 @@ RETURNING *;
SELECT * FROM media_notes WHERE id = $1;
-- name: GetMediaNotes :many
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
-- name: UpdateMediaNote :one
UPDATE media_notes SET
@@ -719,7 +719,7 @@ RETURNING *;
SELECT * FROM media_highlights WHERE id = $1;
-- name: GetMediaHighlights :many
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
-- name: UpdateMediaHighlight :one
UPDATE media_highlights SET
@@ -735,6 +735,251 @@ RETURNING *;
-- name: DeleteMediaHighlight :exec
DELETE FROM media_highlights WHERE id = $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (highlights)
-- ============================================
-- name: GetMediaHighlightByDedupKey :one
SELECT * FROM media_highlights
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1;
-- name: CreateMediaHighlightFull :one
INSERT INTO media_highlights (
media_item_id, user_id, selection_text,
start_position, end_position, color, note_text,
percentage_start, percentage_end,
epubcfi_start, epubcfi_end,
chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
) RETURNING *;
-- name: UpdateMediaHighlightForSync :one
UPDATE media_highlights SET
selection_text = $2,
start_position = $3,
end_position = $4,
color = $5,
note_text = $6,
percentage_start = $7,
percentage_end = $8,
epubcfi_start = $9,
epubcfi_end = $10,
chapter_reference = $11,
last_modified_at = $12,
last_modified_source = $13,
device_sync_data = $14,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaHighlightByDedupKey :exec
UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaHighlightByID :exec
UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredHighlightTombstones :exec
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (notes)
-- ============================================
-- name: GetMediaNoteByDedupKey :one
SELECT * FROM media_notes
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1;
-- name: CreateMediaNoteFull :one
INSERT INTO media_notes (
media_item_id, user_id, content, position,
percentage_location, character_start, character_end,
epubcfi_location, chapter_reference, paragraph_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING *;
-- name: UpdateMediaNoteForSync :one
UPDATE media_notes SET
content = $2,
position = $3,
percentage_location = $4,
character_start = $5,
character_end = $6,
epubcfi_location = $7,
chapter_reference = $8,
paragraph_reference = $9,
last_modified_at = $10,
last_modified_source = $11,
device_sync_data = $12,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaNoteByDedupKey :exec
UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaNoteByID :exec
UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredNoteTombstones :exec
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (bookmarks)
-- ============================================
-- name: GetMediaBookmarkByDedupKey :one
SELECT * FROM media_bookmarks
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1;
-- name: CreateMediaBookmarkFull :one
INSERT INTO media_bookmarks (
media_item_id, user_id, page_number, chapter_number,
cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) RETURNING *;
-- name: UpdateMediaBookmarkForSync :one
UPDATE media_bookmarks SET
page_number = $2,
chapter_number = $3,
cfi_position = $4,
title = $5,
position = $6,
notes = $7,
percentage_location = $8,
epubcfi_location = $9,
chapter_reference = $10,
last_modified_at = $11,
last_modified_source = $12,
device_sync_data = $13,
created_at = created_at
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaBookmarkByDedupKey :exec
UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaBookmarkByID :exec
UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredBookmarkTombstones :exec
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SERVE QUERIES
-- ============================================
-- name: GetActiveAnnotationsForBook :many
SELECT
mh.id,
mh.selection_text,
mh.start_position,
mh.end_position,
mh.color,
mh.created_at,
mh.updated_at,
'highlight' as annotation_type,
mh.percentage_start,
mh.percentage_end,
mh.epubcfi_start,
mh.epubcfi_end,
mh.note_text,
mh.dedup_key,
mh.last_modified_at,
mh.last_modified_source
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
UNION ALL
SELECT
mn.id,
mn.content,
mn.position,
NULL as end_position,
NULL as color,
mn.created_at,
mn.updated_at,
'note' as annotation_type,
mn.percentage_location as percentage_start,
NULL as percentage_end,
mn.epubcfi_location as epubcfi_start,
NULL as epubcfi_end,
NULL as note_text,
mn.dedup_key,
mn.last_modified_at,
mn.last_modified_source
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
ORDER BY created_at DESC;
-- name: GetTombstonedAnnotationsForBook :many
SELECT
mh.id,
mh.dedup_key,
'highlight' as annotation_type,
mh.device_sync_data,
mh.deleted_at
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
UNION ALL
SELECT
mn.id,
mn.dedup_key,
'note' as annotation_type,
mn.device_sync_data,
mn.deleted_at
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
UNION ALL
SELECT
mb.id,
mb.dedup_key,
'bookmark' as annotation_type,
mb.device_sync_data,
mb.deleted_at
FROM media_bookmarks mb
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
ORDER BY deleted_at DESC;
-- Refresh Tokens queries
-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (user_id, token, expires_at)
@@ -1126,6 +1371,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: CreateAutoResolvedSyncConflict :one
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
RETURNING *;
-- name: GetSyncConflict :one
SELECT * FROM sync_conflicts WHERE id = $1;
@@ -1225,7 +1475,7 @@ SELECT
mh.epubcfi_start,
mh.epubcfi_end
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
UNION ALL
SELECT
mn.id,
@@ -1241,7 +1491,7 @@ SELECT
mn.epubcfi_location as epubcfi_start,
NULL as epubcfi_end
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
ORDER BY created_at DESC;
-- name: UpdateDeviceSyncTimestamp :one
@@ -2112,9 +2362,12 @@ RETURNING *;
-- name: GetMediaBookmarks :many
SELECT * FROM media_bookmarks
WHERE media_item_id = $1 AND user_id = $2
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
ORDER BY created_at DESC;
-- name: GetMediaBookmark :one
SELECT * FROM media_bookmarks WHERE id = $1;
-- name: CreateMediaBookmark :one
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)