Files
readest/apps/readest-app/src/libs/sync.ts
T
Huang Xin e29331bea9 fix(sync): prevent cross-device progress overwrite; retry first pull on flaky networks (#4341)
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) <noreply@anthropic.com>
2026-05-28 15:55:07 +02:00

91 lines
2.5 KiB
TypeScript

import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
import { getAPIBaseUrl } from '@/services/environment';
import { getAccessToken } from '@/utils/access';
import { fetchWithTimeout } from '@/utils/fetch';
const SYNC_API_ENDPOINT = getAPIBaseUrl() + '/sync';
export type SyncType = 'books' | 'configs' | 'notes';
export type SyncOp = 'push' | 'pull' | 'both';
interface BookRecord extends BookDataRecord, Book {}
interface BookConfigRecord extends BookDataRecord, BookConfig {}
interface BookNoteRecord extends BookDataRecord, BookNote {}
export interface SyncResult {
books: BookRecord[] | null;
notes: BookNoteRecord[] | null;
configs: BookConfigRecord[] | null;
}
export type SyncRecord = BookRecord & BookConfigRecord & BookNoteRecord;
export interface SyncData {
books?: Partial<BookRecord>[];
notes?: Partial<BookNoteRecord>[];
configs?: Partial<BookConfigRecord>[];
}
export class SyncClient {
/**
* Pull incremental changes since a given timestamp (in ms).
* Returns updated or deleted records since that time.
*/
async pullChanges(
since: number,
type?: SyncType,
book?: string,
metaHash?: string,
): Promise<SyncResult> {
const token = await getAccessToken();
if (!token) throw new Error('Not authenticated');
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}&meta_hash=${metaHash ?? ''}`;
const res = await fetchWithTimeout(
url,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
15000,
);
if (!res.ok) {
const error = await res.json();
throw new Error(`Failed to pull changes: ${error.error || res.statusText}`);
}
return res.json();
}
/**
* Push local changes to the server.
* Uses last-writer-wins logic as implemented on the server side.
*/
async pushChanges(payload: SyncData): Promise<SyncResult> {
const token = await getAccessToken();
if (!token) throw new Error('Not authenticated');
const res = await fetchWithTimeout(
SYNC_API_ENDPOINT,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
},
15000,
);
if (!res.ok) {
const error = await res.json();
throw new Error(`Failed to push changes: ${error.error || res.statusText}`);
}
return res.json();
}
}