fix(conflicts): resolution writes funnel through SaveProgress — audio position survives
Batch H1 step 3b — THE data fix. Conflict resolution was the one live
erasure writer: applyProgressResolution and its verbatim twin
applyResolution both wrote the winner via a raw UpdateUniversalProgress
whose param list omits audio_offset_seconds/context_text/viewports, and
the SQL's DO UPDATE SET ... = EXCLUDED... NULLed exactly those. The web
UI resolves through POST /api/conflicts/:id/resolve and
/api/conflicts/bulk-resolve (main.js resolveConflict/bulkResolve), and
the reader's mark-as-read fires a resolution first when the reader SSR
stamps data-conflict-id/-winner — so every resolution today erased the
audiobook position and reader context.
Now: ProgressService is a required ConflictHandler dependency; BOTH
call sites (single resolve + bulk resolve) share one
applyProgressResolution that funnels through SaveProgress (the verbatim
twin is deleted). Deliberate choices, per the plan:
- SkipConflictDetection on the resolution write: both flows apply
BEFORE the conflict row is marked resolved, so the funnel's LWW
detector would otherwise file a duplicate pending conflict
(HasRecentConflictResolution is false at apply time).
- Source "conflict_resolution" — the row's last-write attribution; the
winner stays durably recorded in resolution_data.winner.
- Broadcast: true — live devices jump to the winning position.
- The winner snapshot maps percentage/epubcfi/context_text/chapter/
character/page/total_pages; a winner without a CFI clears the locator
pair (ClearLocator) instead of re-pairing with the loser's stale
anchor. Everything the snapshot doesn't carry SURVIVES via the LWW
seed: audio_offset_seconds above all. chapter_progress keeps its
stored value (was overwritten with the book percentage), an absent
percentage survives (was written as 0%).
- Accepted: the resolution captures one reading_history session
(attributed to conflict_resolution; the 2h idle ceiling applies).
Verification: TestH1_ConflictResolutionPreservesAudio drives the real
flow — web push with audio 300s, diverging push from a second source
detects a conflict, resolve with the second source as winner, GET shows
percentage 0.3 AND audio_offset_seconds 300 (previously NULLed), and no
duplicate pending conflict remains. Full contract pin suite green.
The host-run TestConflict*/TestProgress* failures are environmental
(container-only /app/uploads path + db hostname; fail identically at
baseline b4ae423) — the same suites pass in the test-runner container.
This commit is contained in:
1 parent
e83c00f869
commit
8fc74c908a
5 files changed
+166
-124
No files matched your search
@@ -341,6 +341,14 @@ type SaveProgressRequest struct {
|
||||
// percentage restores are approximate, and the next anchor-bearing
|
||||
// write re-establishes the pair. Request-supplied fields always win.
|
||||
ClearLocator bool `json:"-"`
|
||||
|
||||
// SkipConflictDetection declares this write APPLIES a conflict
|
||||
// resolution rather than continuing the sync stream: the two flows
|
||||
// (single + bulk) apply the winner BEFORE the conflict row is marked
|
||||
// resolved, so the funnel's LWW detector would see the losing source
|
||||
// within the 5-minute window and file a duplicate pending conflict on
|
||||
// the item the user just resolved. Server-owned (json:"-").
|
||||
SkipConflictDetection bool `json:"-"`
|
||||
}
|
||||
|
||||
func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequest) (database.ReadingProgress, error) {
|
||||
@@ -529,7 +537,7 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
}
|
||||
|
||||
conflictDetected := false
|
||||
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
|
||||
if !req.SkipConflictDetection && hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
|
||||
if existing.LastSyncTimestamp.Valid {
|
||||
timeDiff := time.Since(existing.LastSyncTimestamp.Time)
|
||||
if timeDiff < 5*time.Minute {
|
||||
|
||||
Reference in new issue
Block a user