From e29331bea9f7a1f80352538d6a5ccdd143ff1848 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 28 May 2026 21:55:07 +0800 Subject: [PATCH] fix(sync): prevent cross-device progress overwrite; retry first pull on flaky networks (#4341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4222. Three changes that together stabilize Readest sync across devices: **Stop the artificial updatedAt bump in useProgressAutoSave** saveConfig unconditionally bumps config.updatedAt = Date.now(), and useProgressAutoSave used to fire saveConfig on the very first relocate after book open — even though that relocate just reflects the position loaded from disk, not user action. The stale local config then looked "newer" than a fresher server-side push, so the next auto-push overwrote the other device's real progress via last-writer-wins. The hook now snapshots the loaded location and skips saveConfig when the in-memory location still matches it. **Retry the first config pull with backoff + release the gate** useProgressSync gates pushes behind a successful pull (so a brand-new import can't clobber the server's real progress). But handleAutoSync only re-arms on progress.location changes, so a single failed pull (Android cold-start contention, Wi-Fi/LTE handoff, captive portal) used to block every push for the whole reader session. The new pullWithRetry retries on backoff (1500/4000/10000 ms) and releases the gate after exhaustion — server-side last-writer-wins still protects the cross-device case (a stale local push with an older updated_at loses to a fresher server record). sync-book-progress events reset the chain so manual pull-to-refresh recovers cleanly. Fetch timeout bumped from 8s to 15s to better tolerate slow networks in that cold-start window. **Server piggybacks books.progress off configs push** /api/sync POST now updates books.progress + books.updated_at for each upserted config, gated by .lt('updated_at') so a concurrent newer books push is never downgraded and a missing row is a silent no-op (useBooksSync still seeds new rows from the library page). The in-reader syncBooks round-trip is dropped — the reader now sends one POST per auto-save instead of two, and the books row stays consistent with config pushes even while a reader stays open (#4198). Co-authored-by: Claude Opus 4.7 (1M context) --- .../hooks/useProgressAutoSave.test.tsx | 126 +++++++++++++++++ .../__tests__/hooks/useProgressSync.test.tsx | 132 +++++++++++++++++- .../app/reader/hooks/useProgressAutoSave.ts | 32 ++++- .../src/app/reader/hooks/useProgressSync.ts | 95 ++++++++++--- apps/readest-app/src/libs/sync.ts | 4 +- apps/readest-app/src/pages/api/sync.ts | 54 +++++++ 6 files changed, 415 insertions(+), 28 deletions(-) create mode 100644 apps/readest-app/src/__tests__/hooks/useProgressAutoSave.test.tsx diff --git a/apps/readest-app/src/__tests__/hooks/useProgressAutoSave.test.tsx b/apps/readest-app/src/__tests__/hooks/useProgressAutoSave.test.tsx new file mode 100644 index 00000000..a06645d5 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useProgressAutoSave.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; + +type Progress = { location: string } | null; + +const h = vi.hoisted(() => { + const makeStore = (state: T) => { + const fn = () => state; + (fn as unknown as { getState: () => T }).getState = () => state; + return fn as (() => T) & { getState: () => T }; + }; + + const state = { + config: { location: 'cfi-loc', updatedAt: 1000 } as { location: string; updatedAt: number }, + progress: { location: 'cfi-loc' } as Progress, + previewMode: false, + }; + + return { + makeStore, + state, + saveConfigMock: vi.fn(async () => {}), + }; +}); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig: { name: 'env' } }), +})); + +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: h.makeStore({ + getConfig: () => h.state.config, + saveConfig: h.saveConfigMock, + }), +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: h.makeStore({ + getProgress: () => h.state.progress, + getViewState: () => ({ previewMode: h.state.previewMode }), + }), +})); + +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: h.makeStore({ settings: { version: 1 } }), +})); + +import { useProgressAutoSave } from '@/app/reader/hooks/useProgressAutoSave'; + +const flushDebouncedSave = async () => { + await act(async () => { + // debounce 1000ms + inner setTimeout 500ms + slack + vi.advanceTimersByTime(2000); + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +}; + +beforeEach(() => { + vi.useFakeTimers(); + h.saveConfigMock.mockClear(); + h.state.config = { location: 'cfi-loc', updatedAt: 1000 }; + h.state.progress = { location: 'cfi-loc' }; + h.state.previewMode = false; +}); + +afterEach(() => { + vi.useRealTimers(); + cleanup(); +}); + +describe('useProgressAutoSave', () => { + test('skips save on book open when location matches the loaded config', async () => { + // Bug from issue #4222: opening a book where the in-memory location still + // matches what was loaded from disk should not call saveConfig — doing so + // would artificially bump config.updatedAt and let a stale local push + // overwrite a newer server-side config (the progress someone else just + // read on another device). + renderHook(() => useProgressAutoSave('h1-view1')); + await flushDebouncedSave(); + + expect(h.saveConfigMock).not.toHaveBeenCalled(); + }); + + test('saves once the location advances past the loaded position', async () => { + const { rerender } = renderHook(() => useProgressAutoSave('h1-view1')); + await flushDebouncedSave(); + expect(h.saveConfigMock).not.toHaveBeenCalled(); + + // Simulate the reader advancing to a new location (either user pagination + // or applyRemoteProgress.view.goTo). The config's location is what + // setProgress would have updated, and progress reference changes too. + h.state.config = { location: 'cfi-loc-next', updatedAt: 1000 }; + h.state.progress = { location: 'cfi-loc-next' }; + rerender(); + await flushDebouncedSave(); + + expect(h.saveConfigMock).toHaveBeenCalledTimes(1); + }); + + test('saves when applyRemoteProgress moves the view to a newer remote location', async () => { + // Mirrors the cross-device sync case: device opens at the local position + // (loaded from disk), then the pull lands a newer remote position and + // view.goTo(remote) fires. Auto-save must persist the new location so the + // outgoing push carries the remote-applied progress, not the stale local. + const { rerender } = renderHook(() => useProgressAutoSave('h1-view1')); + await flushDebouncedSave(); + expect(h.saveConfigMock).not.toHaveBeenCalled(); + + h.state.config = { location: 'cfi-remote', updatedAt: 1000 }; + h.state.progress = { location: 'cfi-remote' }; + rerender(); + await flushDebouncedSave(); + + expect(h.saveConfigMock).toHaveBeenCalledTimes(1); + }); + + test('skips save while in preview mode', async () => { + h.state.previewMode = true; + h.state.config = { location: 'cfi-different', updatedAt: 1000 }; + h.state.progress = { location: 'cfi-different' }; + renderHook(() => useProgressAutoSave('h1-view1')); + await flushDebouncedSave(); + + expect(h.saveConfigMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx b/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx index b7287911..445b36e8 100644 --- a/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx @@ -31,7 +31,11 @@ const h = vi.hoisted(() => { user: { id: 'u1' }, syncConfigsMock: vi.fn(async () => {}), syncBooksMock: vi.fn(async () => {}), - state: { syncedConfigs: [] as unknown[] | null }, + state: { + syncedConfigs: [] as unknown[] | null, + progress: { location: 'cfi-loc' } as { location: string } | null, + }, + eventListeners: new Map void>>(), }; }); @@ -65,7 +69,7 @@ vi.mock('@/store/readerStore', () => ({ renderer: { getContents: () => [], primaryIndex: 0 }, goTo: vi.fn(), }), - getProgress: () => ({ location: 'cfi-loc' }), + getProgress: () => h.state.progress, setHoveredBookKey: vi.fn(), getViewState: () => ({ previewMode: false }), }), @@ -92,6 +96,25 @@ vi.mock('@/libs/document', () => ({ CFI: { compare: () => 0 }, })); +vi.mock('@/utils/event', () => ({ + eventDispatcher: { + on: (name: string, fn: (e: CustomEvent) => void) => { + const set = h.eventListeners.get(name) ?? new Set(); + set.add(fn); + h.eventListeners.set(name, set); + }, + off: (name: string, fn: (e: CustomEvent) => void) => { + h.eventListeners.get(name)?.delete(fn); + }, + dispatch: (name: string, detail: unknown) => { + const listeners = h.eventListeners.get(name); + if (!listeners) return; + const event = new CustomEvent(name, { detail }); + for (const fn of [...listeners]) fn(event); + }, + }, +})); + import { useProgressSync } from '@/app/reader/hooks/useProgressSync'; import { SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants'; @@ -107,6 +130,8 @@ beforeEach(() => { h.syncConfigsMock.mockClear(); h.syncBooksMock.mockClear(); h.state.syncedConfigs = []; + h.state.progress = { location: 'cfi-loc' }; + h.eventListeners.clear(); }); afterEach(() => { @@ -114,14 +139,109 @@ afterEach(() => { cleanup(); }); +const flushMicrotasks = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; + +const advance = async (ms: number) => { + await act(async () => { + vi.advanceTimersByTime(ms); + await flushMicrotasks(); + }); +}; + +const pullCallCount = () => + h.syncConfigsMock.mock.calls.filter((c) => (c as unknown[])[3] === 'pull').length; +const pushCallCount = () => + h.syncConfigsMock.mock.calls.filter((c) => (c as unknown[])[3] === 'push').length; + describe('useProgressSync', () => { - test('auto-sync push also pushes the books row so other devices see fresh progress', async () => { + test('auto-sync push only hits the configs lane; the server piggybacks books.progress', async () => { + // Issue #4198 used to be fixed by a second syncBooks call from the + // reader so that other devices' library pull-to-refresh would see fresh + // progress while a reader stayed open. The /api/sync POST handler now + // updates books.progress + books.updated_at off the same configs push, + // so the reader-side syncBooks round-trip is gone. renderHook(() => useProgressSync('h1-view1')); await flushAutoSync(); - // configs lane still pushed expect(h.syncConfigsMock).toHaveBeenCalledWith(expect.any(Array), 'h1', 'm1', 'push'); - // books lane also pushed with the in-memory library Book - expect(h.syncBooksMock).toHaveBeenCalledWith([h.libraryBook], 'push'); + expect(h.syncBooksMock).not.toHaveBeenCalled(); + }); + + test('retries the first pull on failure with backoff, then releases the gate', async () => { + // Pull failure is simulated by a mock that resolves without ever flipping + // h.state.syncedConfigs to a non-null array — the same observable state + // as a real pullChanges that threw and skipped setSyncResult. Without + // retries the configs sync would be stuck on this single failed attempt + // for the whole reader session (handleAutoSync only re-arms on + // progress.location changes), so the user's progress never reaches the + // server until they reopen the book. + h.state.syncedConfigs = null; + const { rerender } = renderHook(() => useProgressSync('h1-view1')); + + // Initial attempt fires from the [progress] effect on mount. + await advance(0); + expect(pullCallCount()).toBe(1); + + // First backoff = 1500ms. + await advance(1500); + expect(pullCallCount()).toBe(2); + + // Second backoff = 4000ms. + await advance(4000); + expect(pullCallCount()).toBe(3); + + // Third backoff = 10000ms. + await advance(10000); + expect(pullCallCount()).toBe(4); + + // Gate released after exhausted retries — a subsequent location change + // takes the push branch instead of queueing another pull. Simulate the + // user paginating: mutate the shared progress state and force a render + // so the [progress?.location] effect re-arms handleAutoSync. + h.state.progress = { location: 'cfi-loc-next' }; + rerender(); + await advance(SYNC_PROGRESS_INTERVAL_SEC * 1000 + 100); + expect(pushCallCount()).toBeGreaterThanOrEqual(1); + }); + + test('a successful pull cancels the pending retry chain', async () => { + // Render with the default mock (syncedConfigs = [], which the [syncedConfigs] + // effect treats as a successful empty pull → configPulled flips on mount). + renderHook(() => useProgressSync('h1-view1')); + + await advance(0); + const initialPulls = pullCallCount(); + + // Wait past every retry window — nothing should fire because the gate + // is already open and the retry timer was cancelled. + await advance(20000); + expect(pullCallCount()).toBe(initialPulls); + }); + + test('sync-book-progress event resets and re-runs the pull chain', async () => { + h.state.syncedConfigs = null; + renderHook(() => useProgressSync('h1-view1')); + + await advance(0); + // Let one backoff fire, then user invokes a manual refresh. + await advance(1500); + const callsBeforeRefresh = pullCallCount(); + expect(callsBeforeRefresh).toBe(2); + + act(() => { + const listeners = h.eventListeners.get('sync-book-progress'); + listeners?.forEach((fn) => + fn(new CustomEvent('sync-book-progress', { detail: { bookKey: 'h1-view1' } })), + ); + }); + await flushMicrotasks(); + // The refresh issues a fresh pull immediately. + expect(pullCallCount()).toBe(callsBeforeRefresh + 1); + + // And the retry chain restarts from delay[0]. + await advance(1500); + expect(pullCallCount()).toBe(callsBeforeRefresh + 2); }); }); diff --git a/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts b/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts index a81d13ea..4bb3c9d1 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; @@ -11,6 +11,15 @@ export const useProgressAutoSave = (bookKey: string) => { const { getProgress } = useReaderStore(); const progress = getProgress(bookKey); + // Tracks the location we last persisted (or, before the first save, the + // location loaded from disk at book open). We skip saveConfig when the + // in-memory location matches — saveConfig unconditionally bumps + // config.updatedAt, and a bump on the initial relocate makes the local + // record look newer than a fresher server-side push, so the next sync + // overwrites the server's progress with the stale local one (issue #4222). + const lastSavedLocationRef = useRef(null); + const initializedRef = useRef(false); + // eslint-disable-next-line react-hooks/exhaustive-deps const saveBookConfig = useCallback( debounce(() => { @@ -20,13 +29,34 @@ export const useProgressAutoSave = (bookKey: string) => { if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return; const config = getConfig(bookKey); if (!config) return; + const currentLocation = config.location ?? null; + if (!initializedRef.current) { + initializedRef.current = true; + lastSavedLocationRef.current = currentLocation; + return; + } + if (currentLocation === lastSavedLocationRef.current) return; const settings = useSettingsStore.getState().settings; await saveConfig(envConfig, bookKey, config, settings); + lastSavedLocationRef.current = currentLocation; }, 500); }, 1000), [], ); + useEffect(() => { + // Snapshot the loaded-from-disk location before any progress events fire, + // so we don't treat the initial relocate as a user-driven change. + if (!initializedRef.current) { + const config = getConfig(bookKey); + if (config) { + initializedRef.current = true; + lastSavedLocationRef.current = config.location ?? null; + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey]); + useEffect(() => { saveBookConfig(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts index e8140ea2..c19b3ac7 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts @@ -3,7 +3,6 @@ import { useAuth } from '@/context/AuthContext'; import { useSync } from '@/hooks/useSync'; import { BookConfig, FIXED_LAYOUT_FORMATS } from '@/types/book'; import { useBookDataStore } from '@/store/bookDataStore'; -import { useLibraryStore } from '@/store/libraryStore'; import { useReaderStore } from '@/store/readerStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; @@ -14,41 +13,50 @@ import { eventDispatcher } from '@/utils/event'; import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants'; import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi'; +// Backoff schedule for the first-pull retry on book open. After these +// attempts the gate releases unconditionally so the user's progress can +// still sync out even if the server keeps timing out (high Android network +// concurrency, captive portal, transient 5xx). Total window ≈ 15.5s. +const PULL_RETRY_DELAYS_MS = [1500, 4000, 10000]; + export const useProgressSync = (bookKey: string) => { const _ = useTranslation(); const { getConfig, setConfig, getBookData } = useBookDataStore(); const { getView, getProgress, setHoveredBookKey } = useReaderStore(); const { settings } = useSettingsStore(); - const { syncedConfigs, syncConfigs, syncBooks } = useSync(bookKey); + const { syncedConfigs, syncConfigs } = useSync(bookKey); const { user } = useAuth(); const progress = getProgress(bookKey); const configPulled = useRef(false); const hasPulledConfigOnce = useRef(false); + const pullAttempt = useRef(0); + const pullInFlight = useRef(false); + const pullRetryTimer = useRef | null>(null); + + const clearPendingPullRetry = () => { + if (pullRetryTimer.current !== null) { + clearTimeout(pullRetryTimer.current); + pullRetryTimer.current = null; + } + }; const pushConfig = async (bookKey: string, config: BookConfig | null) => { const book = getBookData(bookKey)?.book; if (!config || !book || !user) return; - const bookHash = bookKey.split('-')[0]!; + const bookHash = book.hash; const metaHash = book.metaHash; const newConfig = { ...config, bookHash, metaHash }; const compressedConfig = JSON.parse( serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG), ); delete compressedConfig.booknotes; + // The /api/sync POST handler piggybacks books.progress + books.updated_at + // off this configs push (saves the separate syncBooks round-trip that + // used to keep the library record fresh while a reader stayed open — + // see issue #4198). useBooksSync still seeds new books rows when the + // user is on the library page. await syncConfigs([compressedConfig], bookHash, metaHash, 'push'); - - // Also push the corresponding `books` row. The library sync lane - // (useBooksSync) only runs while the library page is mounted, so while a - // reader stays open the server's `books` record is never re-pushed and - // other devices' library pull-to-refresh keeps showing stale progress - // (issue #4198). useProgressAutoSave has already merged config.progress - // into the in-memory library Book via saveConfig, so we just forward - // that book through the books lane. - const libraryBook = useLibraryStore.getState().library.find((b) => b.hash === bookHash); - if (libraryBook && !libraryBook.deletedAt) { - await syncBooks([libraryBook], 'push'); - } }; const pullConfig = async (bookKey: string) => { @@ -59,9 +67,43 @@ export const useProgressSync = (bookKey: string) => { await syncConfigs([], bookHash, metaHash, 'pull'); }; + // Drives the pull on book open. A successful pull is signalled by the + // [syncedConfigs] effect below flipping `configPulled.current` to true and + // clearing the retry state — so this function just kicks off the next + // pull and (re)schedules a retry. If the gate is still closed after + // PULL_RETRY_DELAYS_MS is exhausted, release it unconditionally so the + // user's auto-push isn't blocked by a server outage. Re-entry while a + // pull is in flight or a retry timer is pending is a no-op. + const pullWithRetry = useCallback(async () => { + if (configPulled.current) return; + if (pullInFlight.current) return; + if (pullRetryTimer.current !== null) return; + pullInFlight.current = true; + try { + await pullConfig(bookKey); + } finally { + pullInFlight.current = false; + } + if (configPulled.current) return; + if (pullAttempt.current >= PULL_RETRY_DELAYS_MS.length) { + // Best-effort release. The server-side last-writer-wins compare still + // protects the cross-device case (a stale local push with an older + // updated_at will lose to a fresher server record). + configPulled.current = true; + return; + } + const delay = PULL_RETRY_DELAYS_MS[pullAttempt.current]!; + pullAttempt.current += 1; + pullRetryTimer.current = setTimeout(() => { + pullRetryTimer.current = null; + if (!configPulled.current) pullWithRetry(); + }, delay); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey]); + const syncConfig = async () => { if (!configPulled.current) { - pullConfig(bookKey); + pullWithRetry(); } else { // Skip pushes while previewing a deep-link target — the position in // memory reflects the annotation, not what the user is actually reading. @@ -90,8 +132,13 @@ export const useProgressSync = (bookKey: string) => { const handleSyncBookProgress = async (event: CustomEvent) => { const { bookKey: syncBookKey } = event.detail; if (syncBookKey === bookKey) { + // Manual pull-to-refresh: tear down any prior retry chain so the new + // attempt starts fresh, rather than being short-circuited by the + // "retry already pending" guard in pullWithRetry. configPulled.current = false; - await pullConfig(bookKey); + pullAttempt.current = 0; + clearPendingPullRetry(); + await pullWithRetry(); } }; @@ -119,14 +166,20 @@ export const useProgressSync = (bookKey: string) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [progress?.location]); - // Pull: pull progress once when the book is opened + // Pull: pull progress once when the book is opened, with retry on failure useEffect(() => { if (!progress || hasPulledConfigOnce.current) return; hasPulledConfigOnce.current = true; - pullConfig(bookKey); + pullWithRetry(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [progress]); + // Clean up any pending retry timer on unmount so it doesn't fire after the + // reader has been torn down. + useEffect(() => { + return () => clearPendingPullRetry(); + }, []); + const applyRemoteProgress = async (syncedConfigs: BookConfig[]) => { const config = getConfig(bookKey); const book = getBookData(bookKey)?.book; @@ -189,6 +242,10 @@ export const useProgressSync = (bookKey: string) => { useEffect(() => { if (!configPulled.current && syncedConfigs) { configPulled.current = true; + // Pull succeeded — cancel any in-flight retry chain and reset the + // attempt counter so a future sync-book-progress event starts clean. + pullAttempt.current = 0; + clearPendingPullRetry(); applyRemoteProgress(syncedConfigs).catch((error) => { console.error('Failed to apply remote progress', error); }); diff --git a/apps/readest-app/src/libs/sync.ts b/apps/readest-app/src/libs/sync.ts index 78b4e1c7..562b5908 100644 --- a/apps/readest-app/src/libs/sync.ts +++ b/apps/readest-app/src/libs/sync.ts @@ -48,7 +48,7 @@ export class SyncClient { Authorization: `Bearer ${token}`, }, }, - 8000, + 15000, ); if (!res.ok) { @@ -77,7 +77,7 @@ export class SyncClient { }, body: JSON.stringify(payload), }, - 8000, + 15000, ); if (!res.ok) { diff --git a/apps/readest-app/src/pages/api/sync.ts b/apps/readest-app/src/pages/api/sync.ts index 7123e2e1..fb2a22c1 100644 --- a/apps/readest-app/src/pages/api/sync.ts +++ b/apps/readest-app/src/pages/api/sync.ts @@ -310,6 +310,60 @@ export async function POST(req: NextRequest) { if (configsResult?.error) throw new Error(configsResult.error); if (notesResult?.error) throw new Error(notesResult.error); + // Piggyback the per-book reading progress from the configs push onto the + // matching `books` row. Other devices' library pull-to-refresh reads + // books.progress + books.updated_at, so without this the row would stay + // stale until the user navigates back to the library and useBooksSync + // re-pushes. The .lt('updated_at') predicate keeps last-writer-wins — + // a concurrent newer books push is never downgraded — and a missing + // row is a silent no-op (useBooksSync will insert it later). + type BookProgressUpdate = { + book_hash: string; + progress: [number, number]; + updated_at: string; + }; + const bookProgressUpdates: BookProgressUpdate[] = []; + for (const rec of (configsResult.data ?? []) as unknown as DBBookConfig[]) { + if (!rec.book_hash || !rec.updated_at || rec.progress == null) continue; + let parsed: unknown; + try { + parsed = typeof rec.progress === 'string' ? JSON.parse(rec.progress) : rec.progress; + } catch { + continue; + } + if ( + !Array.isArray(parsed) || + parsed.length !== 2 || + typeof parsed[0] !== 'number' || + typeof parsed[1] !== 'number' + ) { + continue; + } + bookProgressUpdates.push({ + book_hash: rec.book_hash, + progress: [parsed[0], parsed[1]], + updated_at: rec.updated_at, + }); + } + + if (bookProgressUpdates.length > 0) { + await Promise.all( + bookProgressUpdates.map(async (u) => { + const { error } = await supabase + .from('books') + .update({ progress: u.progress, updated_at: u.updated_at }) + .eq('user_id', user.id) + .eq('book_hash', u.book_hash) + .lt('updated_at', u.updated_at); + if (error) { + // Best-effort: never fail the configs push because of this side + // effect — useBooksSync will reconcile the row later. + console.warn('books.progress piggyback failed for', u.book_hash, error.message); + } + }), + ); + } + return NextResponse.json( { books: booksResult?.data || [],