Files
readest/apps/readest-app/src/hooks/useReplicaPull.ts
T
Huang Xin 1eae2af23e feat(sync): batch replica sync into one /api/sync/replicas request (#4109)
Auto-sync triggers (boot non-settings, focus, visibilitychange, online,
periodic) used to fan out N parallel `GET /api/sync/replicas?kind=…`
requests, one per replica kind. With 5 kinds today and the focus path
firing on every foreground transition, that's 5x the Cloudflare Worker
invocations of what the work actually requires.

Server: extend POST /api/sync/replicas to accept a batched-pull body
(`{ cursors: [{kind, since}, …] }`) alongside the existing push
(`{ rows: […] }`). Per-kind queries fan out via Promise.all — Supabase
calls inside the Worker aren't billed as Cloudflare requests, so DB
load is unchanged while Worker invocations collapse from N to 1.

Client/manager: add `client.pullBatch` and `manager.pullMany` that
share the existing cursor/HLC machinery. The boot path's `since=null`
override carries over via `pullMany(kinds, { since: null })`.

Orchestrator: `triggerIncrementalPullAll` now does ONE pullMany call
then fans out per-kind apply via Promise.allSettled. Boot does the
same for non-settings kinds (settings stays a single call to preserve
its apply-first ordering invariant).

Foreground triggers: listen to BOTH `focus` and `visibilitychange`,
sharing one throttle. focus is fastest on iOS Tauri WKWebView (~T=0,
~400ms ahead of visibilitychange). visibilitychange is the only
signal that fires on browser tab switching — focus does not. Drops
the Supabase user-ref-change listener (was the slowest of the three
foreground signals; redundant with the DOM events).

Bonus: `useBooksSync` now serializes `handleAutoSync` against
`pullLibrary` via the shared `isPullingRef` gate. The two paths used
to fire two concurrent `/api/sync?type=books` requests on the same
`since` value at startup; now whichever runs first claims the gate
and the other skips (throttle's `emitLast` retries afterwards).

Per session: boot 5→2 Worker calls. Per foreground trigger: 5→1.
2026-05-09 16:18:46 +02:00

675 lines
27 KiB
TypeScript

import { useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useCustomDictionaryStore, findDictionaryByContentId } from '@/store/customDictionaryStore';
import {
useCustomFontStore,
findFontByContentId,
migrateLegacyFonts,
} from '@/store/customFontStore';
import {
useCustomTextureStore,
findTextureByContentId,
migrateLegacyTextures,
} from '@/store/customTextureStore';
import { useCustomOPDSStore, findOPDSCatalogByContentId } from '@/store/customOPDSStore';
import { transferManager } from '@/services/transferManager';
import { getReplicaSync, subscribeReplicaSyncReady } from '@/services/sync/replicaSync';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
import { fontAdapter } from '@/services/sync/adapters/font';
import { textureAdapter } from '@/services/sync/adapters/texture';
import { opdsCatalogAdapter } from '@/services/sync/adapters/opdsCatalog';
import { settingsAdapter, type SettingsRemoteRecord } from '@/services/sync/adapters/settings';
import {
applyRemoteSettings,
clearStoredEncryptedHashes,
getStoredLastSeenCipher,
publishSettingsIfChanged,
} from '@/services/sync/replicaSettingsSync';
import { useSettingsStore } from '@/store/settingsStore';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import {
replicaPullAndApply,
type PullAndApplyDeps,
type ReplicaLocalRecord,
} from '@/services/sync/replicaPullAndApply';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import { getAccessToken } from '@/utils/access';
import { isSyncCategoryEnabled } from '@/services/sync/syncCategories';
import { uniqueId } from '@/utils/misc';
import type { EnvConfigType } from '@/services/environment';
import type { AppService, BaseDir } from '@/types/system';
import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { CustomFont } from '@/styles/fonts';
import type { CustomTexture } from '@/styles/textures';
import type { OPDSCatalog } from '@/types/opds';
import type { Hlc, ReplicaRow } from '@/types/replica';
import type { SystemSettings } from '@/types/settings';
export type ReplicaKind = 'dictionary' | 'font' | 'texture' | 'opds_catalog' | 'settings';
export interface UseReplicaPullOpts {
/** Replica kinds this page wants pulled. */
kinds: readonly ReplicaKind[];
/** Delay before firing the pull. Defaults to 5s — keeps the boot
* critical path clean and lets feature mounts hydrate first. */
delayMs?: number;
}
const REPLICA_PULL_DEFAULT_DELAY_MS = 5_000;
/** Periodic incremental-pull cadence for long-lived foreground tabs. */
const REPLICA_PULL_PERIODIC_INTERVAL_MS = 5 * 60 * 1000;
/**
* Minimum gap between focus-triggered pulls. iOS Tauri WKWebView fires
* `focus` twice during a single foreground transition (~8ms apart) and
* `visibilitychange` an additional ~400ms later — the throttle
* collapses that burst to one pull. Rapid alt-tab cycles on desktop
* are handled by the same gate. `online` and the periodic timer
* bypass this — they're independent signals.
*/
const REPLICA_PULL_FOREGROUND_THROTTLE_MS = 10_000;
// Module-level dedup so navigating between pages (library → reader → …)
// doesn't fire a fresh boot pull every time. Once a kind has had its
// boot pull initiated (or completed) it stays in this set until the
// page unloads. Subsequent re-syncs for the same kind go through the
// incremental auto-pull path below.
const pulledKinds = new Set<ReplicaKind>();
// Per-kind in-flight gate covering BOTH boot and incremental pulls.
// Prevents concurrent pulls (e.g. visibilitychange firing while the
// boot pull is still resolving, or two triggers landing in the same
// tick).
const pullInFlight = new Set<ReplicaKind>();
// Union of every kind any mount has ever asked us to keep in sync.
// Drives the visibility / online / periodic-tick fan-out, which fires
// for the lifetime of the tab regardless of which page is currently
// mounted.
const registeredKinds = new Set<ReplicaKind>();
// Latest service + envConfig captured by a hook mount; the auto-sync
// triggers read this so the listeners (installed once) don't capture
// stale references when the env hot-reloads.
let autoSyncContext: { service: AppService; envConfig: EnvConfigType } | null = null;
let autoSyncListenersInstalled = false;
let periodicTimer: ReturnType<typeof setInterval> | null = null;
let lastForegroundPullAt = 0;
// Module-level mirror of `useAuth().user`. The auto-sync listeners run
// at module scope and have no React access; they consult this flag to
// short-circuit when there is no signed-in user (no point hitting
// /api/sync — it would 401 anyway). Kept in sync by the hook.
let hasCurrentUser = false;
// Shared promise for the boot-time settings pull. All other kinds'
// boot pulls await this so applyRemoteSettings has a chance to seed
// `lastPublishedFields` with server-authoritative values before
// dict/font/texture/opds_catalog auto-saves fire — without that
// ordering, those auto-saves diff against `undefined` on a fresh
// boot and republish the local default (e.g.
// `dictionarySettings.providerOrder`) with a fresh HLC, clobbering
// cross-device state under per-field LWW.
let settingsBootPullPromise: Promise<void> | null = null;
/**
* Per-kind config consumed by `buildReplicaPullDeps`. The factory fills
* in everything that is structurally identical across kinds (creating
* bundle dirs, queueing downloads, checking files exist, queueing
* upload of the local copy, gating on auth). Per-kind logic stays
* here: the adapter, base dir, find/hydrate/apply/soft-delete store
* accessors.
*/
interface ReplicaPullConfig<T extends ReplicaLocalRecord> {
kind: ReplicaKind;
/** Required for binary-bearing kinds; omitted for metadata-only kinds. */
baseDir?: BaseDir;
adapter: ReplicaAdapter<T>;
findByContentId: (id: string) => T | undefined;
hydrateLocalStore?: (envConfig: EnvConfigType) => Promise<void>;
applyRemote: (record: T) => void;
softDeleteByContentId: (id: string) => void;
/** Forwarded to PullAndApplyDeps; see that field for semantics. */
silentDecrypt?: boolean;
/** Forwarded to PullAndApplyDeps; see that field for semantics. */
onSaltNotFound?: (paths: readonly string[]) => void;
}
/**
* Build the deps the orchestrator hands to `replicaPullAndApply`.
*
* - Boot path: omits `pullOverride`, so `pull` calls `manager.pull(kind)`
* per kind (network round-trip per kind). The boot path also keeps
* the `isAuthenticated` gate, which doubles as the per-kind sync-
* category gate.
* - Incremental path: passes a `pullOverride` returning rows the
* batched `manager.pullMany` already fetched. `isAuthenticated` is
* skipped because the orchestrator pre-filtered by
* `isSyncCategoryEnabled` before issuing the batched request.
*/
const buildReplicaPullDeps = <T extends ReplicaLocalRecord>(
manager: ReplicaSyncManager,
service: AppService,
envConfig: EnvConfigType,
config: ReplicaPullConfig<T>,
pullOpts?: { since?: Hlc | null },
pullOverride?: () => Promise<ReplicaRow[]>,
): PullAndApplyDeps<T> => ({
adapter: config.adapter,
// Boot path passes { since: null } so we always re-fetch and apply
// locally, ignoring any previously-advanced cursor. The incremental
// path passes pullOverride so a single batched `manager.pullMany`
// result is fanned out to per-kind apply without re-hitting the wire.
pull: pullOverride ?? (() => manager.pull(config.kind, pullOpts)),
findByContentId: config.findByContentId,
hydrateLocalStore: config.hydrateLocalStore
? () => config.hydrateLocalStore!(envConfig)
: undefined,
applyRemote: config.applyRemote,
softDeleteByContentId: config.softDeleteByContentId,
silentDecrypt: config.silentDecrypt,
onSaltNotFound: config.onSaltNotFound,
// The bundle / binary callbacks below are only reached when the
// adapter declares a `binary` capability — replicaPullAndApply
// short-circuits metadata-only kinds before invoking them. The
// non-null assertion on baseDir is therefore safe in the binary
// path; metadata-only kinds (opds_catalog) leave config.baseDir
// unset and never hit these.
createBundleDir: async () => {
const id = uniqueId();
await service.createDir(id, config.baseDir!, true);
return id;
},
queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) =>
transferManager.queueReplicaDownload(config.kind, contentId, displayTitle, files, base),
filesExist: async (bundleDir, filenames) => {
for (const filename of filenames) {
const exists = await service.exists(`${bundleDir}/${filename}`, config.baseDir!);
if (!exists) return false;
}
return true;
},
queueLocalBinaryUpload: async (record) => {
await queueReplicaBinaryUpload(config.kind, record, service);
},
// The pull skips when this resolves false. We piggyback the
// user-facing category gate here so disabling a kind in
// `User → Manage Sync` no-ops the pull (no HTTP, no warnings)
// alongside the auth precheck — same effect, half the wiring.
// Skipped on the incremental path: the orchestrator pre-filters
// by category before dispatching the batched request, so this
// would just re-check what's already been gated.
isAuthenticated: pullOverride
? undefined
: async () => {
if (!isSyncCategoryEnabled(config.kind)) return false;
return !!(await getAccessToken());
},
});
const dictionaryPullConfig: ReplicaPullConfig<ImportedDictionary> = {
kind: 'dictionary',
baseDir: 'Dictionaries',
adapter: dictionaryAdapter,
// Page may mount before loadCustomDictionaries has hydrated the
// in-memory store, so the dedup helper falls back to settings.
findByContentId: findDictionaryByContentId,
// Pull-side relies on the in-memory dict store reflecting persisted
// state — without this, the auto-persist fired by applyRemoteDictionary
// would write back only the just-applied rows and clobber every
// persisted dict that hadn't been hydrated by an Annotator/Settings
// mount. Library-page refreshes were the visible victim.
hydrateLocalStore: (envConfig) =>
useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig),
applyRemote: (dict) => useCustomDictionaryStore.getState().applyRemoteDictionary(dict),
softDeleteByContentId: (id) => useCustomDictionaryStore.getState().softDeleteByContentId(id),
};
const fontPullConfig: ReplicaPullConfig<CustomFont> = {
kind: 'font',
baseDir: 'Fonts',
adapter: fontAdapter,
findByContentId: findFontByContentId,
hydrateLocalStore: async (envConfig) => {
await useCustomFontStore.getState().loadCustomFonts(envConfig);
// Rehash legacy flat-path fonts so the user doesn't have to
// re-import them by hand to get them onto other devices.
await migrateLegacyFonts(envConfig);
},
applyRemote: (font) => useCustomFontStore.getState().applyRemoteFont(font),
softDeleteByContentId: (id) => useCustomFontStore.getState().softDeleteByContentId(id),
};
const texturePullConfig: ReplicaPullConfig<CustomTexture> = {
kind: 'texture',
baseDir: 'Images',
adapter: textureAdapter,
findByContentId: findTextureByContentId,
hydrateLocalStore: async (envConfig) => {
await useCustomTextureStore.getState().loadCustomTextures(envConfig);
// Rehash legacy flat-path textures so the user doesn't have to
// re-import them by hand to get them onto other devices.
await migrateLegacyTextures(envConfig);
},
applyRemote: (texture) => useCustomTextureStore.getState().applyRemoteTexture(texture),
softDeleteByContentId: (id) => useCustomTextureStore.getState().softDeleteByContentId(id),
};
const opdsCatalogPullConfig: ReplicaPullConfig<OPDSCatalog> = {
kind: 'opds_catalog',
// metadata-only — no baseDir
adapter: opdsCatalogAdapter,
findByContentId: findOPDSCatalogByContentId,
hydrateLocalStore: (envConfig) => useCustomOPDSStore.getState().loadCustomOPDSCatalogs(envConfig),
applyRemote: (catalog) => useCustomOPDSStore.getState().applyRemoteCatalog(catalog),
softDeleteByContentId: (id) => useCustomOPDSStore.getState().softDeleteByContentId(id),
};
const settingsPullConfig = (envConfig: EnvConfigType): ReplicaPullConfig<SettingsRemoteRecord> => ({
kind: 'settings',
// metadata-only — no baseDir
adapter: settingsAdapter,
// Synthesize a "local" record carrying the persisted cipher
// fingerprint so the orchestrator's cipher-fingerprint comparison
// works for settings the same way it does for OPDS:
// * fingerprint matches → skip prompt (already-decrypted ciphers
// unchanged); no spam on refresh
// * fingerprint differs (rotation / fresh device / new device A
// just set credentials) → prompt fires for the user to enter
// the passphrase
// The empty patch is fine: applyRow re-applies metadata-only kinds
// unconditionally for the actual data.
findByContentId: () => ({
name: 'singleton' as const,
patch: {} as Partial<SystemSettings>,
lastSeenCipher: getStoredLastSeenCipher(),
}),
applyRemote: (record) => applyRemoteSettings(envConfig, record),
// Settings is a singleton — never tombstoned. The server-side
// forget-passphrase wipe doesn't touch this row.
softDeleteByContentId: () => {},
// Auto-recovery for the orphan-cipher case: clear the persisted
// "already-published" hash so the next save re-encrypts under the
// current salt and overwrites the orphan. Then trigger an
// immediate re-publish so the user doesn't have to touch settings
// before the server heals itself.
onSaltNotFound: (paths) => {
clearStoredEncryptedHashes(paths);
const settings = useSettingsStore.getState().settings;
if (settings) void publishSettingsIfChanged(settings);
},
});
/**
* Per-kind dispatch for both the boot pull (one HTTP per kind) and the
* incremental apply (rows already fetched in a batch). Keeping the
* switch keeps the generic record type sound — collapsing the configs
* into a Record<ReplicaKind, ReplicaPullConfig<...>> would force a
* contravariant cast.
*/
const runPullForKind = async (
kind: ReplicaKind,
service: AppService,
envConfig: EnvConfigType,
pullOpts?: { since?: Hlc | null },
pullOverride?: () => Promise<ReplicaRow[]>,
): Promise<void> => {
const ctx = getReplicaSync();
if (!ctx) return;
switch (kind) {
case 'dictionary':
await replicaPullAndApply(
buildReplicaPullDeps(
ctx.manager,
service,
envConfig,
dictionaryPullConfig,
pullOpts,
pullOverride,
),
);
return;
case 'font':
await replicaPullAndApply(
buildReplicaPullDeps(
ctx.manager,
service,
envConfig,
fontPullConfig,
pullOpts,
pullOverride,
),
);
return;
case 'texture':
await replicaPullAndApply(
buildReplicaPullDeps(
ctx.manager,
service,
envConfig,
texturePullConfig,
pullOpts,
pullOverride,
),
);
return;
case 'opds_catalog':
await replicaPullAndApply(
buildReplicaPullDeps(
ctx.manager,
service,
envConfig,
opdsCatalogPullConfig,
pullOpts,
pullOverride,
),
);
return;
case 'settings':
await replicaPullAndApply(
buildReplicaPullDeps(
ctx.manager,
service,
envConfig,
settingsPullConfig(envConfig),
pullOpts,
pullOverride,
),
);
return;
}
};
/**
* Cursor-based incremental pull. One batched HTTP round-trip for every
* registered kind that has had its boot pull initiated and whose sync
* category is enabled. Concurrent triggers (focus + online firing in
* the same tick, periodic timer racing with a focus event) collapse
* via the per-kind `pullInFlight` set: if any of the kinds we'd batch
* is already in flight, we skip the redundant call entirely.
*/
const triggerIncrementalPullAll = (): void => {
if (!hasCurrentUser) return;
if (!autoSyncContext) return;
const ctx = getReplicaSync();
if (!ctx) return;
const { service, envConfig } = autoSyncContext;
const kindsToPull: ReplicaKind[] = [];
for (const kind of registeredKinds) {
// Skip until the kind's boot pull has at least been initiated. Boot
// does the full re-fetch (since=null); incremental builds on whatever
// cursor that pull advanced.
if (!pulledKinds.has(kind)) continue;
if (pullInFlight.has(kind)) continue;
if (!isSyncCategoryEnabled(kind)) continue;
kindsToPull.push(kind);
}
if (kindsToPull.length === 0) return;
for (const kind of kindsToPull) pullInFlight.add(kind);
void (async () => {
try {
// ONE network round-trip for every eligible kind. Per-kind apply
// runs in parallel below — adapters are independent, and one
// kind's apply error doesn't poison the others.
let rowsByKind: Map<string, ReplicaRow[]>;
try {
rowsByKind = await ctx.manager.pullMany(kindsToPull);
} catch (err) {
console.warn('replica batch pull failed', err);
return;
}
await Promise.allSettled(
kindsToPull.map(async (kind) => {
const rows = rowsByKind.get(kind) ?? [];
try {
await runPullForKind(kind, service, envConfig, undefined, async () => rows);
} catch (err) {
console.warn(`replica ${kind} incremental apply failed`, err);
}
}),
);
} finally {
for (const kind of kindsToPull) pullInFlight.delete(kind);
}
})();
};
// Two foreground signals, sharing one throttle:
// Listening to both with one throttle catches every transition without
// double-pulling. Some debug logs are kept on purpose: foreground-sync
// regressions have historically been hard to reproduce.
const onForegroundReturn = (source: 'focus' | 'visibilitychange'): void => {
// Visibility events fire both directions; only act on the visible side.
if (source === 'visibilitychange' && typeof document !== 'undefined') {
if (document.visibilityState !== 'visible') return;
}
const now = Date.now();
if (now - lastForegroundPullAt < REPLICA_PULL_FOREGROUND_THROTTLE_MS) return;
lastForegroundPullAt = now;
triggerIncrementalPullAll();
};
const onFocus = (): void => onForegroundReturn('focus');
const onVisibilityChange = (): void => onForegroundReturn('visibilitychange');
const onOnline = (): void => {
triggerIncrementalPullAll();
};
const onPeriodicTick = (): void => {
// Don't burn battery on a backgrounded tab — the focus listener
// will fire a catch-up pull when the window returns to foreground.
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
triggerIncrementalPullAll();
};
/**
* Idempotent settings boot pull. First caller starts the pull; every
* subsequent caller (other kinds awaiting the seed, additional mounts)
* receives the same promise. Settings is also added to
* `registeredKinds` so the auto-pull triggers fan it out alongside the
* caller's requested kinds.
*/
const ensureSettingsBootPulled = (service: AppService, envConfig: EnvConfigType): Promise<void> => {
if (settingsBootPullPromise) return settingsBootPullPromise;
registeredKinds.add('settings');
pulledKinds.add('settings');
pullInFlight.add('settings');
settingsBootPullPromise = runPullForKind('settings', service, envConfig, { since: null })
.catch((err) => {
console.warn('replica settings pull failed', err);
pulledKinds.delete('settings');
})
.finally(() => {
pullInFlight.delete('settings');
});
return settingsBootPullPromise;
};
/**
* Install document/window listeners + periodic interval for incremental
* pulls. Idempotent — first call wires everything, subsequent calls
* just refresh `autoSyncContext` (so listeners always see the latest
* appService / envConfig). Listeners stay attached for the lifetime of
* the page; in production this runs exactly once.
*/
const installAutoSyncListeners = (service: AppService, envConfig: EnvConfigType): void => {
autoSyncContext = { service, envConfig };
if (autoSyncListenersInstalled) return;
autoSyncListenersInstalled = true;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange);
}
if (typeof window !== 'undefined') {
window.addEventListener('focus', onFocus);
window.addEventListener('online', onOnline);
}
periodicTimer = setInterval(onPeriodicTick, REPLICA_PULL_PERIODIC_INTERVAL_MS);
};
/**
* Schedules a deferred replica pull for the requested kinds. Mount this
* on a page that wants those kinds present (library, reader, etc.).
*
* Two pull lifecycles share this hook:
*
* 1. Boot pull — one-shot per session (dedup'd by `pulledKinds`),
* fired `delayMs` after first mount. Uses `since=null` for a full
* re-fetch that recovers from any prior cursor-advanced-but-not-
* applied gaps. The `settings` kind is implicitly pulled FIRST
* (regardless of whether the caller requested it) so the rest of
* the kinds' auto-saves don't republish stale local providerOrder /
* providerEnabled with a fresh HLC.
* 2. Incremental auto-pull — runs for the lifetime of the tab via
* module-level visibility / online listeners + a 5-minute interval.
* Uses the persisted cursor for cheap delta fetches. Concurrent
* triggers collapse via `pullInFlight`.
*
* Listeners install once on the first mount that has a ready replica-
* sync singleton, and stay attached after subsequent unmounts so a
* long-lived tab keeps catching up across other devices' edits.
*/
export const useReplicaPull = ({
kinds,
delayMs = REPLICA_PULL_DEFAULT_DELAY_MS,
}: UseReplicaPullOpts): void => {
const { envConfig, appService } = useEnv();
const { user } = useAuth();
// Stable cache key so the effect doesn't re-run when the caller
// passes a freshly-allocated array literal each render.
const kindsKey = kinds.join(',');
// Mirror the React-side auth state into the module-level flag the
// auto-sync listeners read. Kept in its own effect so a user ref
// change (Supabase TOKEN_REFRESHED reissues the user object on
// foreground / refresh) doesn't tear down the boot-pull effect.
useEffect(() => {
hasCurrentUser = !!user;
}, [user]);
useEffect(() => {
if (!appService) return;
if (!user) return;
for (const kind of kinds) registeredKinds.add(kind);
let timer: ReturnType<typeof setTimeout> | null = null;
let unsubscribe: (() => void) | null = null;
const schedule = () => {
installAutoSyncListeners(appService, envConfig);
if (timer) return;
// Settings is implicitly pulled first regardless of whether the
// caller asked for it (e.g. the reader page only requests
// dictionary/font/texture). Without that seeding, applyRemote
// for a binary kind on a fresh device appends the new id to the
// local default `dictionarySettings.providerOrder` and the
// ensuing auto-save publishes that order with a fresh HLC,
// overwriting the cross-device order set on another device.
const otherPending = kinds.filter((k) => k !== 'settings' && !pulledKinds.has(k));
const needsSettings = !pulledKinds.has('settings');
if (otherPending.length === 0 && !needsSettings) return;
timer = setTimeout(() => {
void (async () => {
// Await the settings boot pull before dispatching the others.
// Subsequent mounts share `settingsBootPullPromise` so the
// pull only happens once per session.
await ensureSettingsBootPulled(appService, envConfig);
// Boot path skips disabled kinds: enabling a category later
// re-fires `triggerIncrementalPullAll` from a focus event,
// which will fetch the missed rows. This keeps boot bandwidth
// proportional to what the user actually sync's.
const eligible = otherPending.filter(
(k) => !pulledKinds.has(k) && isSyncCategoryEnabled(k),
);
if (eligible.length === 0) return;
// Claim both slots up front so a concurrently-scheduled mount
// (e.g., library + reader mounting back-to-back) doesn't
// double-pull, and so a focus / online trigger landing
// mid-boot doesn't fire a second pull alongside this one.
// On failure we release `pulledKinds` so a subsequent
// navigation can retry; `pullInFlight` is always cleared.
for (const kind of eligible) {
pulledKinds.add(kind);
pullInFlight.add(kind);
}
// ONE batched HTTP round-trip for all non-settings kinds at
// boot, with `since=null` so each kind does a full re-fetch
// (mirrors the old per-kind `runPullForKind(kind, …, {since: null})`
// semantics). Per-kind apply runs in parallel afterwards.
const ctx = getReplicaSync();
if (!ctx) {
for (const kind of eligible) {
pulledKinds.delete(kind);
pullInFlight.delete(kind);
}
return;
}
let rowsByKind: Map<string, ReplicaRow[]>;
try {
rowsByKind = await ctx.manager.pullMany(eligible, { since: null });
} catch (err) {
console.warn('replica boot batch pull failed', err);
for (const kind of eligible) {
pulledKinds.delete(kind);
pullInFlight.delete(kind);
}
return;
}
await Promise.allSettled(
eligible.map(async (kind) => {
try {
const rows = rowsByKind.get(kind) ?? [];
await runPullForKind(kind, appService, envConfig, undefined, async () => rows);
} catch (err) {
console.warn(`replica ${kind} boot apply failed`, err);
pulledKinds.delete(kind);
} finally {
pullInFlight.delete(kind);
}
}),
);
})();
}, delayMs);
};
if (getReplicaSync()) {
schedule();
} else {
// Hard-refresh race: appService resolved before
// EnvContext.initReplicaSync finished (loadSettings is async,
// setAppService runs first). Wait for the ready signal so the
// pull still fires once the singleton lands.
unsubscribe = subscribeReplicaSyncReady(schedule);
}
return () => {
if (timer) clearTimeout(timer);
if (unsubscribe) unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kindsKey, appService, envConfig, delayMs, user]);
};
/** Test seam — clear all module-level state and tear down listeners. */
export const __resetReplicaPullForTests = (): void => {
pulledKinds.clear();
pullInFlight.clear();
registeredKinds.clear();
autoSyncContext = null;
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange);
}
if (typeof window !== 'undefined') {
window.removeEventListener('focus', onFocus);
window.removeEventListener('online', onOnline);
}
if (periodicTimer) {
clearInterval(periodicTimer);
periodicTimer = null;
}
autoSyncListenersInstalled = false;
lastForegroundPullAt = 0;
settingsBootPullPromise = null;
hasCurrentUser = false;
};