Files
readest/apps/readest-app/src/services/sync/replicaCursorStore.ts
T
Huang Xin cbdc3b8f52 feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
* feat(sync): cross-device dictionary sync

Custom MDict / StarDict / DICT / SLOB dictionaries now sync across
signed-in devices via the replica layer.

- Store mutations publish replica rows with field-level LWW + tombstones.
- Re-importing the same content (renamed or after delete) preserves the
  user's label and reincarnates the server row instead of duplicating.
- Manifest commits after binary upload so other devices never see a row
  whose binaries aren't on cloud storage yet.
- Pull-side orchestrator creates a placeholder dict, queues the binaries
  via TransferManager, and clears the unavailable flag on completion.
- Toast copy branches by transfer kind so dict uploads don't read
  "Book uploaded".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): boot pull and binary download path

- Defer the boot pull until TransferManager is initialized so download
  enqueues aren't dropped.
- Auto-persist the local dict store after applyRemoteDictionary; otherwise
  the next loadCustomDictionaries wipes the in-memory rows.
- Boot pull passes since=null so a device whose cursor advanced past
  unpersisted rows can still recover.
- Skip pulling when not authenticated instead of logging
  "SyncError: Not authenticated" on every boot of a signed-out device.
- downloadReplicaFile resolves the destination against the kind's base
  dir; binaries previously landed at the literal lfp and openFile then
  failed with "File not found".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sync): per-page useReplicaPull hook

Lifts the boot-time pull out of EnvContext into a hook each page mounts
for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }).
Library page and the shared Reader component opt in. The hook fires 10s
after page load (so feature mounts hydrate first), dedups per-kind
across navigation, and releases the slot on failure so a later mount
can retry. Future kinds plug into the hook's per-kind switch.

Also closes two refresh-loop bugs:

- Hydrate the dict store from settings BEFORE the apply loop, so the
  auto-persist doesn't clobber persisted rows that the in-memory store
  hadn't yet read. Library-page refresh was the visible victim.
- Skip the download queue when every manifest file is already on disk
  under the resolved bundle dir. Refreshing is a no-op; partial-
  download recovery still queues because some files would be missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:39:38 +02:00

72 lines
2.2 KiB
TypeScript

import type { AppService } from '@/types/system';
import type { Hlc } from '@/types/replica';
import type { CursorStore } from './replicaSyncManager';
export interface SettingsCursorStoreOpts {
/** Debounce window for the load-merge-save flush. Defaults to 1s. */
debounceMs?: number;
/** Test seam — defaults to the platform setTimeout. */
setTimeoutFn?: typeof setTimeout;
/** Test seam. */
clearTimeoutFn?: typeof clearTimeout;
}
/**
* Production CursorStore backed by appService.loadSettings + saveSettings.
*
* Cursors are cached in memory so get() is sync. set() debounces a save
* that does a fresh load-merge-save round-trip — this keeps us from
* clobbering fields written elsewhere in the same session (e.g., the
* library page's setSettings).
*
* Cursor advance is best-effort: a lost save just means the next pull
* re-fetches a few rows. We never block the sync flow on disk IO.
*/
export const createSettingsCursorStore = (
appService: AppService,
opts: SettingsCursorStoreOpts = {},
): CursorStore => {
const cache = new Map<string, Hlc>();
const setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
const clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
const debounceMs = opts.debounceMs ?? 1000;
let pending: ReturnType<typeof setTimeout> | null = null;
void (async () => {
try {
const settings = await appService.loadSettings();
for (const [k, v] of Object.entries(settings.lastSyncedAtReplicas ?? {})) {
cache.set(k, v as Hlc);
}
} catch (err) {
console.warn('replica cursor hydrate failed', err);
}
})();
const flush = async () => {
try {
const settings = await appService.loadSettings();
settings.lastSyncedAtReplicas = Object.fromEntries(cache);
await appService.saveSettings(settings);
} catch (err) {
console.warn('replica cursor save failed', err);
}
};
const scheduleFlush = () => {
if (pending !== null) clearTimeoutFn(pending);
pending = setTimeoutFn(() => {
pending = null;
void flush();
}, debounceMs);
};
return {
get: (kind) => cache.get(kind) ?? null,
set: (kind, hlc) => {
cache.set(kind, hlc);
scheduleFlush();
},
};
};