Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/features/cloud-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Valpr Reader allows you to synchronize your library, reading positions, bookmark
- **Automatic Replication:** Synchronization runs in the background. Progress updates and checkpoints are saved locally first, then debounced and sent to the cloud.
- **Offline-First:** You never lose reading progress if your internet connection drops. All changes are queued in browser IndexedDB and reconciled once you reconnect.
- **Prioritized Sync (read-ready):** Each sync downloads reading position + manual bookmarks for currently-reading books first (in progress and opened within the last 30 days) — the payload you need before starting to read. Cards show a **read-ready badge** (spinning = syncing reading state, check = synced), and the header spinner reports `Reading state n of m` while it runs. Opening a read-ready book opens instantly from the local copy without a blocking cloud sync screen. The deferred upload for those books plus everything else (statistics, goals, profiles, tags, finished/unread/stale books) follows immediately after in the background. Read-ready means _you are seeing the latest_; the full `Sync complete` toast still only fires when everything is done.
- **Manual Sync in the Reader:** When pending changes exist, the reader footer shows a sync button. Tapping it runs the same two stages for the open book only: it pulls the latest reading position + manual bookmarks first (surfacing a jump offer when the cloud is ahead — never auto-jumping), pushes local changes up, then syncs the remaining queued types. Background autosave sync stays upload-only. Reconnecting an expired session first pulls the open book's reading state so the jump offer appears without waiting, then runs the full library sync — which skips re-downloading that state via its up-to-date checks — and refreshes the open book again the same offer-only way.
- **Smart Conflict Resolution:**
- **Reading Position:** Automatically takes the latest reading position timestamp.
- **Bookmarks, Tags, Statistics, Goals, Profiles:** Normal sync always merges both sides together, so changes from every device are kept.
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/lib/functions/replication/cloud-reauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,28 @@ async function promptManualRetry(
* Explicit re-auth from a button click (banner / top-bar icon / Settings).
* Call `StorageOAuthManager.openAuthWindowSync()` synchronously in the click
* handler and pass it in so iOS/Safari does not block the popup.
*
* `beforeFullSync` is an optional best-effort fast path that runs after
* reconnect and before the full library sync (e.g. the reader pulls the
* open book's read-state first so it can offer a jump without waiting).
* It never blocks or fails the full sync, which retries and reports
* through its own flows.
*/
export async function reconnectAndSyncNow(
window: Window,
sourceName: string,
preOpenedWindow: Window | null | undefined,
storageSources: BooksDbStorageSource[] = []
storageSources: BooksDbStorageSource[] = [],
beforeFullSync?: () => Promise<void>
): Promise<boolean> {
if (!sourceName) return false;
const connected = await StorageOAuthManager.reconnect(window, sourceName, preOpenedWindow);
if (!connected) return false;
try {
await beforeFullSync?.();
} catch {
// Intentionally silent; syncAfterReconnect owns error reporting.
}
return syncAfterReconnect(window, sourceName, storageSources);
}

Expand Down
178 changes: 178 additions & 0 deletions apps/web/src/lib/functions/replication/cloud-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { isNetworkUnreachableError } from '$lib/functions/replication/error-hand
import { ApiStorageHandler } from '$lib/data/storage/handler/api-handler';
import { logger } from '$lib/data/logger';
import type { BooksDbStorageSource } from '$lib/data/database/books-db/versions/books-db';
import type { BaseStorageHandler } from '$lib/data/storage/handler/base-handler';
import type { ReplicationContext } from '$lib/functions/replication/replication-progress';

export const SYNC_DATA_TYPES = [
StorageDataType.PROGRESS,
Expand Down Expand Up @@ -364,6 +366,182 @@ export async function triggerCloudSync(
}
}

/**
* Total error normalizer for sync legs: any rejection is a failure, so this
* never returns a falsy value. A bare `err.message` mapping turns thrown
* strings (and message-less rejections) into `undefined` — false success.
*/
export function asSyncErrorMessage(err: unknown): string {
if (typeof err === 'string') return err || 'Unknown sync error';
const message = (err as { message?: unknown } | null | undefined)?.message;
if (typeof message === 'string' && message) return message;
return 'Unknown sync error';
}

/**
* Read-state types for the open book, honoring the primary vs secondary
* scope: secondary clouds only ever see book-scoped payloads. Both
* `READ_READY_DATA_TYPES` happen to be book-scoped today; the filter keeps
* that invariant explicit if the set ever grows.
*/
export function openBookReadStateTypes(isPrimary: boolean): StorageDataType[] {
return READ_READY_DATA_TYPES.filter((t) => isPrimary || BOOK_SCOPED_DATA_TYPES.includes(t));
}

export interface OpenBookReadStateParams {
localHandler: BaseStorageHandler;
externalHandler: BaseStorageHandler;
context: ReplicationContext;
/**
* Candidate types (the caller applies the primary vs secondary scope via
* `openBookReadStateTypes`); only read-state types are ever downloaded.
*/
dataTypes: StorageDataType[];
/**
* Runs after the download lands locally. Lets the reader re-resolve its
* resume position and offer a jump without ever auto-navigating.
*/
onDownloaded?: () => Promise<void> | void;
}

/**
* Phase-1 download only for the open book: pulls reading position + manual
* bookmarks so the reader can offer a jump before the full library sync
* runs. Uploads ride along with the full sync moments later. Returns an
* error message (undefined on success); failures stay silent by design and
* the full sync retries and reports them through its own flows.
*
* No redownload on the follow-up pass: this runs through the same
* `replicateData` path, so the full sync's repeat over this book degrades
* to metadata checks — the progress up-to-date gate and the user-bookmarks
* exact-state marker recorded here skip both bodies.
*/
export async function downloadOpenBookReadState({
localHandler,
externalHandler,
context,
dataTypes,
onDownloaded
}: OpenBookReadStateParams): Promise<string | undefined> {
const readStateTypes = dataTypes.filter((t) => READ_READY_DATA_TYPES.includes(t));
if (!readStateTypes.length) return undefined;

const downError = await replicateData(
externalHandler,
localHandler,
false,
[context],
readStateTypes
).catch(asSyncErrorMessage);
if (downError) return downError;

try {
await onDownloaded?.();
} catch {
// Best-effort UI refresh: never fail the sync when the reader
// re-reads its local position.
}

return undefined;
}

export interface SingleBookTwoPhaseParams {
localHandler: BaseStorageHandler;
externalHandler: BaseStorageHandler;
context: ReplicationContext;
/**
* Already-scoped data types (the caller applies the primary vs secondary
* `BOOK_SCOPED_DATA_TYPES` filter) — used as-is so scoping has a single
* source of truth.
*/
dataTypes: StorageDataType[];
refreshDataList: boolean;
/**
* Runs after the Phase-1 download (read-state) lands locally, before the
* Phase-1 upload. Lets the reader re-resolve its resume position and offer
* a jump without ever auto-navigating.
*/
onPhase1Downloaded?: () => Promise<void> | void;
}

/**
* Two-stage sync for the single open book (reader explicit sync).
*
* Phase 1 downloads then uploads reading position + manual bookmarks
* (`READ_READY_DATA_TYPES`) — the small payload the reader needs first —
* then Phase 2 downloads then uploads everything else queued. Download
* always precedes upload within each phase so the local copy converges
* before publishing; callers flush pending autosaves first so the upload
* publishes the live position rather than stale DB state (LWW guards the
* download leg on last-write-wins types, union merges guard the rest).
* Returns an error message (undefined on success).
*/
export async function replicateSingleBookTwoPhase({
localHandler,
externalHandler,
context,
dataTypes,
refreshDataList,
onPhase1Downloaded
}: SingleBookTwoPhaseParams): Promise<string | undefined> {
const effectiveTypes = [...dataTypes];
if (!effectiveTypes.length) return undefined;

const phase1Types = effectiveTypes.filter((t) => READ_READY_DATA_TYPES.includes(t));
const restTypes = effectiveTypes.filter((t) => !READ_READY_DATA_TYPES.includes(t));
const contexts = [context];
const asError = asSyncErrorMessage;

if (phase1Types.length) {
const downError = await replicateData(
externalHandler,
localHandler,
false,
contexts,
phase1Types
).catch(asError);
if (downError) return downError;

try {
await onPhase1Downloaded?.();
} catch {
// Best-effort UI refresh: never fail the sync when the reader
// re-reads its local position.
}

const upError = await replicateData(
localHandler,
externalHandler,
refreshDataList,
contexts,
phase1Types
).catch(asError);
if (upError) return upError;
}

if (restTypes.length) {
const downError = await replicateData(
externalHandler,
localHandler,
false,
contexts,
restTypes
).catch(asError);
if (downError) return downError;

const upError = await replicateData(
localHandler,
externalHandler,
refreshDataList,
contexts,
restTypes
).catch(asError);
if (upError) return upError;
}

return undefined;
}

export type RecoveryDirection = 'push' | 'pull';

/**
Expand Down
124 changes: 115 additions & 9 deletions apps/web/src/routes/b/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,13 @@
import { reconnectAndSyncNow } from '$lib/functions/replication/cloud-reauth';
import { isNetworkUnreachableError } from '$lib/functions/replication/error-handler';
import { suppressDictionaryScan } from '$lib/functions/suppress-dictionary-scan';
import { BOOK_SCOPED_DATA_TYPES } from '$lib/functions/replication/cloud-sync';
import {
BOOK_SCOPED_DATA_TYPES,
asSyncErrorMessage,
downloadOpenBookReadState,
openBookReadStateTypes,
replicateSingleBookTwoPhase
} from '$lib/functions/replication/cloud-sync';
import {
StorageOAuthManager,
getExpiredSyncTargets,
Expand Down Expand Up @@ -2030,6 +2036,75 @@
nextChapter$.next(nextChapter.reference);
}

/**
* Re-read the open book's local position after a read-state download so a
* newer cloud position surfaces immediately. Offer-only: refreshes the
* resume bookmark and jump offer, never auto-navigates. Best-effort and
* safe to call when no book is open. Awaiting the resolve guarantees its
* convergence write lands before the Phase-1 upload reads the position.
*/
async function refreshOpenBookReadState(): Promise<void> {
const dataId = getBookIdSync();
if (!dataId) return;
try {
bookmarkData = resolveResumeBookmark(dataId);
await bookmarkData.catch(() => undefined);
await refreshUserBookmarks();
maybeOfferJump();
} catch {
// Best-effort UI refresh: sync outcome is reported separately.
}
}

/**
* Open-book-first reconnect prefetch: pulls this book's reading position +
* manual bookmarks right after re-auth, before the full library sync, so a
* newer cloud position surfaces via the jump offer without waiting.
* Best-effort and silent on failure — the full sync retries this download
* (degraded to metadata checks by the up-to-date gates) and reports
* through its own flows. Offer-only, never auto-navigates.
*/
async function prefetchOpenBookReadState(): Promise<void> {
const raw = $rawBookData$;
if (!raw || !localStorageHandler || !externalStorageHandler) return;
const storageSourceName = raw.storageSource || $syncTarget$;
const readStateTypes = openBookReadStateTypes(storageSourceName === $syncTarget$);
if (!readStateTypes.length) return;
const context = { id: raw.id, title: raw.title, imagePath: raw.coverImage };

externalStorageHandler.updateSettings(
window,
false,
$replicationSaveBehavior$,
$statisticsMergeMode$,
$readingGoalsMergeMode$,
$cacheStorageData$,
true,
storageSourceName
);
try {
const downError = await downloadOpenBookReadState({
localHandler: localStorageHandler,
externalHandler: externalStorageHandler,
context,
dataTypes: readStateTypes,
onDownloaded: refreshOpenBookReadState
}).catch(asSyncErrorMessage);
if (downError) logger.warn(`Open-book read-state prefetch failed: ${downError}`);
} finally {
externalStorageHandler.updateSettings(
window,
true,
$replicationSaveBehavior$,
$statisticsMergeMode$,
$readingGoalsMergeMode$,
$cacheStorageData$,
false,
storageSourceName
);
}
}

async function executeReplication(isSilent = true) {
if (isReplicating || !dataToReplicate.length || !$rawBookData$ || !externalStorageHandler) {
return;
Expand Down Expand Up @@ -2078,13 +2153,30 @@
let error: string | undefined;

try {
error = await replicateData(
localStorageHandler,
externalStorageHandler,
refreshDataList,
[context],
types
).catch((err: any) => err.message);
if (!isSilent) {
// Explicit footer sync: two-stage so reading position + manual
// bookmarks converge before the heavier payloads. Flush the debounced
// autosave first so the upload publishes the live position rather
// than pre-turn DB state. Silent background sync stays single-shot
// upload-only.
await flushPendingAutosave().catch(() => undefined);
error = await replicateSingleBookTwoPhase({
localHandler: localStorageHandler,
externalHandler: externalStorageHandler,
context,
dataTypes: types,
refreshDataList,
onPhase1Downloaded: refreshOpenBookReadState
}).catch(asSyncErrorMessage);
} else {
error = await replicateData(
localStorageHandler,
externalStorageHandler,
refreshDataList,
[context],
types
).catch(asSyncErrorMessage);
}
} finally {
externalStorageHandler.updateSettings(
window,
Expand Down Expand Up @@ -2499,7 +2591,19 @@
const preOpened = StorageOAuthManager.openAuthWindowSync(window);
cloudReconnecting = true;
try {
const ok = await reconnectAndSyncNow(window, expiredSyncTarget, preOpened);
const ok = await reconnectAndSyncNow(
window,
expiredSyncTarget,
preOpened,
[],
prefetchOpenBookReadState
);
if (ok && $rawBookData$?.id) {
// The prefetch already surfaced the open book's position; refresh
// again after the full sync in case it pulled anything newer.
// Offer-only, never auto-navigates.
await refreshOpenBookReadState();
}
if (ok && wasFullscreen && !fullscreenManager.fullscreenElement) {
const restored = await fullscreenManager.requestFullscreen(document.documentElement);
if (!restored) {
Expand Down Expand Up @@ -2917,6 +3021,8 @@
<div
tabindex="0"
role="button"
aria-label="Sync now"
data-testid="reader-manual-sync"
class="flex h-full w-8 items-center justify-center text-sm sm:text-lg"
class:text-red-500={externalStorageErrors > 1}
class:animate-pulse={externalStorageErrors > 1 || isReplicating}
Expand Down
Loading
Loading