import type { UserData } from './userData' import { createEmptyUserData, isSameUserData, mergeUserData, normalizeUserData, USER_DATA_VERSION, } from './userData' export interface UserDataDocument extends UserData { version: typeof USER_DATA_VERSION updatedAt: number } export interface UserDataRemote { /** Resolves null when the user has no document yet. */ read: (uid: string) => Promise write: (uid: string, document: UserDataDocument) => Promise } export interface UserDataLocal { read: () => UserData write: (data: UserData) => void /** The account this device last synced with. */ readLastUid: () => string writeLastUid: (uid: string) => void } export class UserDataSyncError extends Error { constructor(public readonly code: 'invalid_remote' | 'remote_failed', options?: { cause?: unknown }) { super(code === 'invalid_remote' ? '云端数据格式无法识别' : '云端同步失败', options) this.name = 'UserDataSyncError' } } /** * One round of read → merge → write. * * - Same account (or a device that never synced): local and cloud are merged. * - Another account synced here last: its data is already in its own cloud * document, so the cloud replaces local instead of leaking into this account. * - An unrecognised cloud document is never overwritten. */ export async function syncUserData( uid: string, local: UserDataLocal, remote: UserDataRemote, now = Date.now(), ): Promise { let raw: unknown | null try { raw = await remote.read(uid) } catch (error) { throw new UserDataSyncError('remote_failed', { cause: error }) } const cloud = raw === null ? createEmptyUserData() : normalizeUserData(raw) if (!cloud) throw new UserDataSyncError('invalid_remote') const lastUid = local.readLastUid() const current = local.read() const base = lastUid && lastUid !== uid ? createEmptyUserData() : current const merged = mergeUserData(base, cloud, now) if (!isSameUserData(merged, current)) local.write(merged) local.writeLastUid(uid) if (raw === null || !isSameUserData(merged, cloud)) { try { await remote.write(uid, { version: USER_DATA_VERSION, ...merged, updatedAt: now }) } catch (error) { throw new UserDataSyncError('remote_failed', { cause: error }) } } return merged } export interface UserDataSyncScheduler { /** Run a sync after `delay` ms; repeated requests are coalesced. */ request: (delay?: number) => void stop: () => void } export interface UserDataSyncSchedulerOptions { run: () => Promise onError?: (error: unknown) => void /** Defaults to retrying every failure. */ shouldRetry?: (error: unknown) => boolean maxRetryDelay?: number } /** * Serialises sync rounds: at most one runs at a time, a request made while one * is running triggers another round afterwards, and failures retry with * exponential backoff until a round succeeds or `stop()` is called. */ export function createUserDataSyncScheduler(options: UserDataSyncSchedulerOptions): UserDataSyncScheduler { const maxRetryDelay = options.maxRetryDelay ?? 30_000 let timer: ReturnType | undefined let running = false let pending = false let stopped = false let retry = 0 function request(delay = 0): void { stopped = false if (running) { pending = true return } clearTimeout(timer) timer = setTimeout(flush, delay) } async function flush(): Promise { timer = undefined if (stopped || running) return running = true pending = false let retryDelay: number | undefined try { await options.run() retry = 0 } catch (error) { options.onError?.(error) if (options.shouldRetry?.(error) ?? true) { retryDelay = Math.min(maxRetryDelay, 1000 * 2 ** retry) retry += 1 } } finally { running = false } if (stopped) return if (pending) request() else if (retryDelay !== undefined) request(retryDelay) } return { request, stop() { stopped = true pending = false retry = 0 clearTimeout(timer) timer = undefined }, } }