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:
John O'Keefe committed 2026-10-17 10:26:22 -04:00
1 parent e83c00f869
commit 8fc74c908a
5 files changed
+166 -124

No files matched your search

+51 -120
View File
@@ -18,12 +18,18 @@ import (
type ConflictHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService
}
func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManager) *ConflictHandler {
// NewConflictHandler requires the shared ProgressService: applying a
// resolution is a progress WRITE and must funnel through SaveProgress so
// the winner's snapshot lands without erasing row fields it doesn't carry
// (audio_offset_seconds survives the write).
func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManager, progressSvc *wsync.ProgressService) *ConflictHandler {
return &ConflictHandler{
db: db,
connManager: connManager,
progressSvc: progressSvc,
}
}
@@ -294,71 +300,64 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
return c.JSON(http.StatusOK, response)
}
// applyProgressResolution writes the winning snapshot through the
// SaveProgress funnel. Deliberate choices (hardening H1):
// - Source "conflict_resolution": the row's last-write attribution; the
// winner's identity is durably recorded on the conflict row
// (resolution_data.winner).
// - SkipConflictDetection: both flows apply BEFORE the conflict row is
// marked resolved, so the funnel's LWW detector would otherwise file a
// duplicate pending conflict on the item just resolved
// (HasRecentConflictResolution is false at apply time).
// - Broadcast: live devices jump to the winning position, same as a
// device push; the write also captures one reading_history session
// (idle-gap ceiling applies) — accepted as the resolution's own event.
// - Every field the winner's snapshot doesn't carry (audio_offset_seconds,
// viewports, zoom, reading_mode, and an absent percentage) SURVIVES via
// the service's LWW seed instead of being NULLed — that is the fix.
// chapter_progress keeps its stored value instead of being overwritten
// with the book-level percentage.
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
req := wsync.SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
Source: "conflict_resolution",
SkipConflictDetection: true,
Broadcast: true,
// A winner without a CFI (percentage-only sources, e.g. Kobo)
// declares no locator: clear the inherited anchor pair instead of
// re-pairing the new percentage with the loser's stale CFI/
// context. A winner WITH a CFI keeps the stored pair coherent.
ClearLocator: true,
}
percentage := 0.0
if p, ok := data["percentage"].(float64); ok {
percentage = p
req.Percentage = &p
}
var epubcfi pgtype.Text
if e, ok := data["epubcfi"].(string); ok {
epubcfi = pgtype.Text{String: e, Valid: true}
req.Epubcfi = &e
req.ClearLocator = false
}
if ct, ok := data["context_text"].(string); ok {
req.ContextText = &ct
}
var chapter pgtype.Int4
if c, ok := data["chapter"].(float64); ok {
chapter = pgtype.Int4{Int32: int32(c), Valid: true}
ch := int(c)
req.Chapter = &ch
}
var characterOffset pgtype.Int8
if c, ok := data["character"].(float64); ok {
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
co := int64(c)
req.CharacterOffset = &co
}
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if err == nil {
currentPage = existingProgress.CurrentPage
totalPages = existingProgress.TotalPages
}
if p, ok := data["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
pg := int(p)
req.CurrentPage = &pg
}
if p, ok := data["total_pages"].(float64); ok {
totalPages = pgtype.Int4{Int32: int32(p), Valid: true}
tp := int(p)
req.TotalPages = &tp
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
PanelNumber: pgtype.Int4{},
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
_, err := h.progressSvc.SaveProgress(context.Background(), req)
return err
}
@@ -699,7 +698,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
continue
}
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
@@ -783,74 +782,6 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
return highestSource, highestData
}
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
percentage := 0.0
if p, ok := data["percentage"].(float64); ok {
percentage = p
}
var epubcfi pgtype.Text
if e, ok := data["epubcfi"].(string); ok {
epubcfi = pgtype.Text{String: e, Valid: true}
}
var chapter pgtype.Int4
if c, ok := data["chapter"].(float64); ok {
chapter = pgtype.Int4{Int32: int32(c), Valid: true}
}
var characterOffset pgtype.Int8
if c, ok := data["character"].(float64); ok {
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
}
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if err == nil {
currentPage = existingProgress.CurrentPage
totalPages = existingProgress.TotalPages
}
if p, ok := data["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
}
if p, ok := data["total_pages"].(float64); ok {
totalPages = pgtype.Int4{Int32: int32(p), Valid: true}
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
PanelNumber: pgtype.Int4{},
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
return err
}
func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
user := c.Get("user").(database.Users)