forked from akai/readest
1eae2af23e
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.
675 lines
27 KiB
TypeScript
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;
|
|
};
|