forked from akai/readest
e29331bea9
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>
91 lines
2.5 KiB
TypeScript
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();
|
|
}
|
|
}
|