diff --git a/apps/docs/features/cloud-sync.md b/apps/docs/features/cloud-sync.md index 95cf190cad..06a0ecdaa7 100644 --- a/apps/docs/features/cloud-sync.md +++ b/apps/docs/features/cloud-sync.md @@ -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. diff --git a/apps/web/src/lib/functions/replication/cloud-reauth.ts b/apps/web/src/lib/functions/replication/cloud-reauth.ts index 7ab39d087b..caceeac965 100644 --- a/apps/web/src/lib/functions/replication/cloud-reauth.ts +++ b/apps/web/src/lib/functions/replication/cloud-reauth.ts @@ -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 ): Promise { 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); } diff --git a/apps/web/src/lib/functions/replication/cloud-sync.ts b/apps/web/src/lib/functions/replication/cloud-sync.ts index 88680082c8..b1335a7893 100644 --- a/apps/web/src/lib/functions/replication/cloud-sync.ts +++ b/apps/web/src/lib/functions/replication/cloud-sync.ts @@ -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, @@ -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; +} + +/** + * 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 { + 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; +} + +/** + * 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 { + 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'; /** diff --git a/apps/web/src/routes/b/+page.svelte b/apps/web/src/routes/b/+page.svelte index 49a4769baf..5dc5e3e56d 100644 --- a/apps/web/src/routes/b/+page.svelte +++ b/apps/web/src/routes/b/+page.svelte @@ -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, @@ -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 { + 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 { + 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; @@ -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, @@ -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) { @@ -2917,6 +3021,8 @@
1} class:animate-pulse={externalStorageErrors > 1 || isReplicating} diff --git a/apps/web/tests/single-book-two-phase.spec.ts b/apps/web/tests/single-book-two-phase.spec.ts new file mode 100644 index 0000000000..9d7a1e6ef1 --- /dev/null +++ b/apps/web/tests/single-book-two-phase.spec.ts @@ -0,0 +1,472 @@ +/** + * @license BSD-3-Clause + * Copyright (c) 2026, ッツ Reader Authors + * All rights reserved. + */ + +import { expect, test } from '@playwright/test'; + +test.describe('Single-book two-phase sync (reader explicit sync)', () => { + test('phase-1 read-state syncs before the rest, with callback between down and up', async ({ + page + }) => { + // The landing route auto-navigates to the library; load it directly so + // the execution context stays stable for page.evaluate. + await page.goto('/manage'); + + const result = await page.evaluate(async () => { + const cloudSyncPath = '/src/lib/functions/replication/cloud-sync.ts'; + const storageTypesPath = '/src/lib/data/storage/storage-types.ts'; + const mod = await import(/* @vite-ignore */ cloudSyncPath); + const typesMod = await import(/* @vite-ignore */ storageTypesPath); + const StorageDataType = typesMod.StorageDataType; + const timeline: string[] = []; + + function makeHandler(name: string) { + return { + name, + storageType: `fake-${name}`, + isCacheDisabled: () => false, + getCurrentStorageSource: () => name, + getFilenameForRecentCheck: async (prefix: string) => { + timeline.push(`${name}:recent:${prefix}`); + return `${prefix}${name}`; + }, + isProgressPresentAndUpToDate: async () => { + timeline.push(`${name}:isProgressUpToDate`); + return false; + }, + getProgress: async () => { + timeline.push(`${name}:getProgress`); + return { exploredCharCount: 1200, progress: 0.1, lastBookmarkModified: 5 }; + }, + saveProgress: async () => { + timeline.push(`${name}:saveProgress`); + }, + getUserBookmarks: async () => { + timeline.push(`${name}:getUserBookmarks`); + return []; + }, + saveUserBookmarks: async () => { + timeline.push(`${name}:saveUserBookmarks`); + }, + isAudioBookPresentAndUpToDate: async () => { + timeline.push(`${name}:isAudioUpToDate`); + return false; + }, + getAudioBook: async () => { + timeline.push(`${name}:getAudioBook`); + return undefined; + }, + isCoverPresentAndUpToDate: async () => true + }; + } + + const local = makeHandler('local'); + const external = makeHandler('external'); + const context = { title: 'Two-Phase Book' }; + + let callbackCalls = 0; + const error = await mod.replicateSingleBookTwoPhase({ + localHandler: local, + externalHandler: external, + context, + dataTypes: [ + StorageDataType.AUDIOBOOK, + StorageDataType.PROGRESS, + StorageDataType.USER_BOOKMARKS + ], + refreshDataList: false, + onPhase1Downloaded: () => { + callbackCalls += 1; + timeline.push('callback:phase1-downloaded'); + } + }); + + return { error: error ?? null, timeline, callbackCalls }; + }); + + expect(result.error).toBeNull(); + expect(result.callbackCalls).toBe(1); + + const t = result.timeline; + const idx = (entry: string) => t.indexOf(entry); + // Phase-1 download (external -> local) precedes the callback, which + // precedes the Phase-1 upload (local -> external). + expect(idx('external:getProgress')).toBeGreaterThanOrEqual(0); + expect(idx('local:saveProgress')).toBeGreaterThanOrEqual(0); + expect(idx('external:getProgress')).toBeLessThan(idx('callback:phase1-downloaded')); + expect(idx('local:saveProgress')).toBeLessThan(idx('callback:phase1-downloaded')); + expect(idx('callback:phase1-downloaded')).toBeLessThan(idx('local:getProgress')); + expect(idx('local:getProgress')).toBeLessThan(idx('external:saveProgress')); + // Phase-2 (audiobook) runs after the Phase-1 upload finished. + expect(idx('external:saveUserBookmarks')).toBeLessThan(idx('external:getAudioBook')); + expect(idx('callback:phase1-downloaded')).toBeLessThan(idx('external:getAudioBook')); + }); + + test('first failing leg aborts the run and skips the callback', async ({ page }) => { + await page.goto('/manage'); + + const result = await page.evaluate(async () => { + const cloudSyncPath = '/src/lib/functions/replication/cloud-sync.ts'; + const storageTypesPath = '/src/lib/data/storage/storage-types.ts'; + const mod = await import(/* @vite-ignore */ cloudSyncPath); + const typesMod = await import(/* @vite-ignore */ storageTypesPath); + const StorageDataType = typesMod.StorageDataType; + const timeline: string[] = []; + + const local = { + storageType: 'fake-local', + isCacheDisabled: () => false, + getFilenameForRecentCheck: async () => 'progress_fake-local', + isProgressPresentAndUpToDate: async () => false, + getProgress: async () => ({ exploredCharCount: 1, progress: 0, lastBookmarkModified: 1 }), + saveProgress: async () => { + timeline.push('local:saveProgress'); + }, + getUserBookmarks: async () => [], + saveUserBookmarks: async () => {}, + isCoverPresentAndUpToDate: async () => true + }; + const external = { + storageType: 'fake-external', + isCacheDisabled: () => false, + getFilenameForRecentCheck: async () => 'progress_fake-external', + isProgressPresentAndUpToDate: async () => false, + getProgress: async () => { + throw new Error('cloud unreachable'); + }, + saveProgress: async () => { + timeline.push('external:saveProgress'); + }, + getUserBookmarks: async () => [], + saveUserBookmarks: async () => {}, + isCoverPresentAndUpToDate: async () => true + }; + + let callbackCalls = 0; + const error = await mod.replicateSingleBookTwoPhase({ + localHandler: local, + externalHandler: external, + context: { title: 'Failing Book' }, + dataTypes: [StorageDataType.PROGRESS], + refreshDataList: false, + onPhase1Downloaded: () => { + callbackCalls += 1; + } + }); + + return { error: error ?? null, timeline, callbackCalls }; + }); + + expect(result.error).toContain('cloud unreachable'); + expect(result.callbackCalls).toBe(0); + expect(result.timeline).toEqual([]); + }); + + test('empty types resolve without touching any handler', async ({ page }) => { + await page.goto('/manage'); + + const result = await page.evaluate(async () => { + const cloudSyncPath = '/src/lib/functions/replication/cloud-sync.ts'; + const mod = await import(/* @vite-ignore */ cloudSyncPath); + let calls = 0; + const handler = { + storageType: 'fake', + isCacheDisabled: () => { + calls += 1; + return false; + } + }; + const error = await mod.replicateSingleBookTwoPhase({ + localHandler: handler, + externalHandler: handler, + context: { title: 'Empty Book' }, + dataTypes: [], + refreshDataList: false + }); + return { error: error ?? null, calls }; + }); + + expect(result.error).toBeNull(); + expect(result.calls).toBe(0); + }); + + test('open-book read-state types stay book-scoped on secondary clouds', async ({ page }) => { + await page.goto('/manage'); + + const result = await page.evaluate(async () => { + const cloudSyncPath = '/src/lib/functions/replication/cloud-sync.ts'; + const mod = await import(/* @vite-ignore */ cloudSyncPath); + return { + primary: mod.openBookReadStateTypes(true), + secondary: mod.openBookReadStateTypes(false) + }; + }); + + expect(result.primary).toEqual(['bookmark', 'userBookmark']); + expect(result.secondary).toEqual(['bookmark', 'userBookmark']); + }); + + test('read-state prefetch downloads position and bookmarks only, never uploads', async ({ + page + }) => { + await page.goto('/manage'); + + const result = await page.evaluate(async () => { + const cloudSyncPath = '/src/lib/functions/replication/cloud-sync.ts'; + const storageTypesPath = '/src/lib/data/storage/storage-types.ts'; + const mod = await import(/* @vite-ignore */ cloudSyncPath); + const typesMod = await import(/* @vite-ignore */ storageTypesPath); + const StorageDataType = typesMod.StorageDataType; + const timeline: string[] = []; + + function makeHandler(name: string) { + return { + name, + storageType: `fake-${name}`, + isCacheDisabled: () => false, + getCurrentStorageSource: () => name, + getFilenameForRecentCheck: async () => `${name}-recent`, + isProgressPresentAndUpToDate: async () => false, + getProgress: async () => { + timeline.push(`${name}:getProgress`); + return { exploredCharCount: 900, progress: 0.09, lastBookmarkModified: 7 }; + }, + saveProgress: async () => { + timeline.push(`${name}:saveProgress`); + }, + getUserBookmarks: async () => { + timeline.push(`${name}:getUserBookmarks`); + return []; + }, + saveUserBookmarks: async () => { + timeline.push(`${name}:saveUserBookmarks`); + }, + isAudioBookPresentAndUpToDate: async () => false, + getAudioBook: async () => { + timeline.push(`${name}:getAudioBook`); + return undefined; + }, + isCoverPresentAndUpToDate: async () => true + }; + } + + const local = makeHandler('local'); + const external = makeHandler('external'); + let callbackCalls = 0; + const error = await mod.downloadOpenBookReadState({ + localHandler: local, + externalHandler: external, + context: { title: 'Prefetch Book' }, + dataTypes: [ + StorageDataType.PROGRESS, + StorageDataType.USER_BOOKMARKS, + StorageDataType.AUDIOBOOK + ], + onDownloaded: () => { + callbackCalls += 1; + } + }); + + return { error: error ?? null, timeline, callbackCalls }; + }); + + expect(result.error).toBeNull(); + expect(result.callbackCalls).toBe(1); + // Download direction only: bodies flow external -> local. + expect(result.timeline).toContain('external:getProgress'); + expect(result.timeline).toContain('local:saveProgress'); + expect(result.timeline).toContain('external:getUserBookmarks'); + expect(result.timeline).toContain('local:saveUserBookmarks'); + // No upload leg and no out-of-scope types. + expect(result.timeline).not.toContain('local:getProgress'); + expect(result.timeline).not.toContain('external:saveProgress'); + expect(result.timeline).not.toContain('external:getAudioBook'); + }); + + test('read-state prefetch failure returns the error without firing the callback', async ({ + page + }) => { + await page.goto('/manage'); + + const result = await page.evaluate(async () => { + const cloudSyncPath = '/src/lib/functions/replication/cloud-sync.ts'; + const storageTypesPath = '/src/lib/data/storage/storage-types.ts'; + const mod = await import(/* @vite-ignore */ cloudSyncPath); + const typesMod = await import(/* @vite-ignore */ storageTypesPath); + const StorageDataType = typesMod.StorageDataType; + + const local = { + storageType: 'fake-local', + isCacheDisabled: () => false, + getFilenameForRecentCheck: async () => 'recent', + isProgressPresentAndUpToDate: async () => false, + getProgress: async () => ({ exploredCharCount: 1, progress: 0, lastBookmarkModified: 1 }), + saveProgress: async () => {}, + getUserBookmarks: async () => [], + saveUserBookmarks: async () => {}, + isCoverPresentAndUpToDate: async () => true + }; + const external = { + storageType: 'fake-external', + isCacheDisabled: () => false, + getFilenameForRecentCheck: async () => 'recent', + isProgressPresentAndUpToDate: async () => false, + getProgress: async () => { + throw new Error('prefetch offline'); + }, + saveProgress: async () => {}, + getUserBookmarks: async () => [], + saveUserBookmarks: async () => {}, + isCoverPresentAndUpToDate: async () => true + }; + + let callbackCalls = 0; + const error = await mod.downloadOpenBookReadState({ + localHandler: local, + externalHandler: external, + context: { title: 'Prefetch Failure Book' }, + dataTypes: [StorageDataType.PROGRESS], + onDownloaded: () => { + callbackCalls += 1; + } + }); + + return { error: error ?? null, callbackCalls }; + }); + + expect(result.error).toContain('prefetch offline'); + expect(result.callbackCalls).toBe(0); + }); + + test('second pass over synced read-state fetches no bodies', async ({ page }) => { + // Proves the no-redownload property: after one download, the progress + // up-to-date gate and the user-bookmarks exact-state marker make a + // repeat pass metadata-only, so the follow-up full sync is cheap. + await page.goto('/manage'); + + const result = await page.evaluate(async () => { + const cloudSyncPath = '/src/lib/functions/replication/cloud-sync.ts'; + const storageTypesPath = '/src/lib/data/storage/storage-types.ts'; + const mod = await import(/* @vite-ignore */ cloudSyncPath); + const typesMod = await import(/* @vite-ignore */ storageTypesPath); + const StorageDataType = typesMod.StorageDataType; + const StorageKey = typesMod.StorageKey; + const CLOUD_TS = 100; + const timeline: string[] = []; + let savedTs = 0; + + const local = { + storageType: StorageKey.BROWSER, + isCacheDisabled: () => false, + isOverwriteMode: () => false, + getCurrentStorageSource: () => 'browser-test', + getFilenameForRecentCheck: async () => `progress_local_ts_${savedTs}`, + isProgressPresentAndUpToDate: async (referenceFilename: string) => { + timeline.push(`local:isProgressUpToDate:${referenceFilename}`); + const match = /(\d+)$/.exec(referenceFilename || ''); + return savedTs >= Number(match?.[1] || 0); + }, + getProgress: async () => { + timeline.push('local:getProgress'); + return { exploredCharCount: 1, progress: 0, lastBookmarkModified: savedTs }; + }, + saveProgress: async (data: { lastBookmarkModified: number }) => { + timeline.push('local:saveProgress'); + savedTs = data.lastBookmarkModified; + }, + getUserBookmarks: async () => { + timeline.push('local:getUserBookmarks'); + return []; + }, + saveUserBookmarks: async () => { + timeline.push('local:saveUserBookmarks'); + }, + isCoverPresentAndUpToDate: async () => true + }; + const external = { + storageType: 'fake-external', + isCacheDisabled: () => false, + isOverwriteMode: () => false, + getCurrentStorageSource: () => 'external-test', + getFilenameForRecentCheck: async () => `progress_external_ts_${CLOUD_TS}`, + isProgressPresentAndUpToDate: async () => false, + getProgress: async () => { + timeline.push('external:getProgress'); + return { exploredCharCount: 1200, progress: 0.12, lastBookmarkModified: CLOUD_TS }; + }, + saveProgress: async () => { + timeline.push('external:saveProgress'); + }, + getUserBookmarks: async () => { + timeline.push('external:getUserBookmarks'); + return []; + }, + saveUserBookmarks: async () => { + timeline.push('external:saveUserBookmarks'); + }, + isCoverPresentAndUpToDate: async () => true, + listFilesWithPrefix: async () => { + timeline.push('external:listFiles'); + return []; + } + }; + + const params = { + localHandler: local, + externalHandler: external, + context: { id: 4242, title: 'No Redownload Book', imagePath: 'cover.png' }, + dataTypes: [StorageDataType.PROGRESS, StorageDataType.USER_BOOKMARKS] + }; + const firstError = await mod.downloadOpenBookReadState(params); + const firstPass = [...timeline]; + timeline.length = 0; + const secondError = await mod.downloadOpenBookReadState(params); + const secondPass = [...timeline]; + + return { + firstError: firstError ?? null, + secondError: secondError ?? null, + firstPass, + secondPass + }; + }); + + expect(result.firstError).toBeNull(); + expect(result.secondError).toBeNull(); + // First pass pulls both bodies. + expect(result.firstPass).toContain('external:getProgress'); + expect(result.firstPass).toContain('local:saveProgress'); + expect(result.firstPass).toContain('external:getUserBookmarks'); + // Second pass: metadata checks only, zero body fetches or saves. + expect(result.secondPass).not.toContain('external:getProgress'); + expect(result.secondPass).not.toContain('local:saveProgress'); + expect(result.secondPass).not.toContain('external:getUserBookmarks'); + expect(result.secondPass).not.toContain('local:saveUserBookmarks'); + expect(result.secondPass).not.toContain('local:getProgress'); + }); + + test('error normalizer never reports failure as success', async ({ page }) => { + await page.goto('/manage'); + + const result = await page.evaluate(async () => { + const cloudSyncPath = '/src/lib/functions/replication/cloud-sync.ts'; + const mod = await import(/* @vite-ignore */ cloudSyncPath); + return { + fromString: mod.asSyncErrorMessage('boom'), + fromError: mod.asSyncErrorMessage(new Error('kaput')), + fromUndefined: mod.asSyncErrorMessage(undefined), + fromEmptyString: mod.asSyncErrorMessage(''), + fromObject: mod.asSyncErrorMessage({ code: 500 }) + }; + }); + + expect(result.fromString).toBe('boom'); + expect(result.fromError).toBe('kaput'); + for (const value of [result.fromUndefined, result.fromEmptyString, result.fromObject]) { + expect(typeof value).toBe('string'); + expect(value.length).toBeGreaterThan(0); + } + }); +});