Add sync conflict detection and resolution system

Implement conflict detection for concurrent reading progress updates from different devices. Adds conflict management endpoints for listing, viewing, and resolving conflicts.

- Add ConflictHandler with CRUD endpoints for conflict management
- Implement automatic conflict detection in KOReader progress updates
- Add WebSocket broadcast for real-time conflict notifications
- Add database query for listing user conflicts by status
- Add integration tests and Bruno API test collection
This commit is contained in:
2026-01-31 11:45:52 -05:00
parent 9b41b3ecb0
commit 2d2d643873
11 changed files with 1084 additions and 16 deletions
+33
View File
@@ -0,0 +1,33 @@
meta {
name: Delete Conflict
type: http
seq: 4
}
delete {
url: {{baseUrl}}/api/conflicts/{{conflict_id}}
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
}
docs: {
Deletes a specific conflict record.
Path Parameters:
- conflict_id: UUID of the conflict to delete
Use this when:
- A conflict was created in error
- You want to dismiss a conflict without resolving it
- The conflict is no longer relevant
Response: 204 No Content on success
Note: This permanently removes the conflict record.
Consider resolving the conflict instead if you want to
maintain an audit trail of what happened.
}
+35
View File
@@ -0,0 +1,35 @@
meta {
name: Dismiss All Resolved Conflicts
type: http
seq: 5
}
post {
url: {{baseUrl}}/api/conflicts/dismiss-all
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
}
docs: {
Deletes all resolved conflicts for the authenticated user.
Use this to:
- Clean up your conflicts list after reviewing resolutions
- Remove old resolved conflicts that are no longer needed
- Maintain a clean conflict history
Response includes:
- deleted: Number of conflict records that were deleted
Example response:
{
"deleted": 5
}
Note: Only resolves conflicts with status "user_resolved"
are deleted. Unresolved conflicts are preserved.
}
+58
View File
@@ -0,0 +1,58 @@
meta {
name: Get Conflict Details
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/conflicts/{{conflict_id}}
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
}
docs: {
Retrieves detailed information about a specific conflict.
Path Parameters:
- conflict_id: UUID of the conflict
Response includes:
- id: Conflict UUID
- media_item_id: Associated book UUID
- media_item_title: Book title
- conflict_type: Type of conflict
- conflict_data: Side-by-side comparison with sources:
* source: Device/source identifier (koreader, kobo, web, etc.)
* timestamp: When this progress was recorded
* data: The conflicting data (percentage, epubcfi, chapter, etc.)
- resolution_status: Current status
- resolution_data: If resolved, includes resolution details
- resolved_by: User ID who resolved it (if applicable)
- resolved_at: When it was resolved (if applicable)
- created_at: When conflict was detected
Example conflict_data:
{
"koreader": {
"source": "koreader",
"timestamp": "2026-01-30T20:10:00Z",
"data": {
"percentage": 0.45,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3
}
},
"kobo": {
"source": "kobo",
"timestamp": "2026-01-30T20:05:00Z",
"data": {
"percentage": 0.42,
"location": "unknown"
}
}
}
}
+36
View File
@@ -0,0 +1,36 @@
meta {
name: List Conflicts
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/conflicts?status=unresolved
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
}
docs: {
Lists all sync conflicts for the authenticated user.
Query Parameters:
- status: Filter by resolution status (unresolved, user_resolved, auto_resolved, all)
Response includes:
- conflicts: Array of conflict details
- total: Total number of conflicts
- unresolved: Number of unresolved conflicts
Each conflict includes:
- id: Conflict UUID
- media_item_id: Associated book UUID
- media_item_title: Book title
- conflict_type: Type of conflict (progress, note, highlight)
- conflict_data: Side-by-side comparison of conflicting data
- resolution_status: Current status
- created_at: When conflict was detected
}
+66
View File
@@ -0,0 +1,66 @@
meta {
name: Resolve Conflict
type: http
seq: 3
}
post {
url: {{baseUrl}}/api/conflicts/{{conflict_id}}/resolve
body: json
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
Content-Type: application/json
}
body:json {
{
"winner": "koreader",
"manual_data": null,
"apply_to_all_future_conflicts": false,
"reason": "User chose more recent progress"
}
}
docs: {
Resolves a sync conflict by choosing which source to use.
Path Parameters:
- conflict_id: UUID of the conflict to resolve
Request Body:
- winner: Source to choose (koreader, kobo, web, manual)
- manual_data: Required if winner is "manual" - contains the merged data
- apply_to_all_future_conflicts: Whether to auto-resolve future conflicts from this source
- reason: Optional explanation for the resolution
Example request body for choosing koreader:
{
"winner": "koreader",
"reason": "More recent progress"
}
Example request body for manual resolution:
{
"winner": "manual",
"manual_data": {
"percentage": 0.43,
"epubcfi": "epubcfi(/6/4/2:20)",
"chapter": 3
},
"reason": "Custom merged position"
}
Response includes:
- conflict_resolved: true if successful
- applied_to: What was updated (progress, annotations)
- devices_synced: List of device IDs that were notified
After resolution:
- The winning data is applied to the reading progress
- All connected devices are notified via WebSocket
- Conflict status changes to "user_resolved"
- Resolution data is stored for audit trail
}