import type { IShare, ISubscription, Thread } from '@wovin/core' import type { AppThread } from '../data/ApplogDB' import { dateNowIso } from '@wovin/core' import { observable, reaction, runInAction } from '@wovin/core/mobx' import { Logger } from 'besonders-logger' import { differenceInSeconds } from 'date-fns' import pull from 'lodash-es/pull' import { useAgent } from '../data/agent/AgentState' import { getApplogDB } from '../data/ApplogDB' import { flushAllPendingBlockContent } from '../data/VMs/BlockVM' import { isOnline } from '../ui/online-second' import { retrievePubToThread } from './retrievePubToThread' import { pushShare, updateSubFromPullData } from './store-sync' import './worker-transferHandlers' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars const DEFAULT_SYNC_INTERVAL = 60 const DEFAULT_MIN_IDLE_SECONDS = 10 // ℹ we store them in localStorage instead of applogs as it's rather device-specific // TODO ... but getting the default from your other device would be nice :p export const syncState = observable({ syncing: null as (null | Array<{ sub?: ISubscription } | { share?: IShare }>), syncInterval: localStorage.getItem('note3.sync.interval') ? Number.parseInt(localStorage.getItem('note3.sync.interval')) : DEFAULT_SYNC_INTERVAL, minIdle: localStorage.getItem('note3.sync.minIdle') ? Number.parseInt(localStorage.getItem('note3.sync.minIdle')) : DEFAULT_MIN_IDLE_SECONDS, errors: [], lastNonIdleTime: null as Date | null, }) reaction(() => syncState.syncInterval, (newInterval) => { LOG('Setting sync interval to', newInterval, 'seconds') localStorage.setItem('note3.sync.interval', newInterval.toString()) stopSyncService() if (syncState.syncInterval > 0) { startSyncService() } }) reaction(() => syncState.minIdle, (newMinIdle) => { LOG('Setting sync minIdle to', newMinIdle, 'seconds') localStorage.setItem('note3.sync.minIdle', newMinIdle.toString()) stopSyncService() if (syncState.minIdle > 0) { startSyncService() } }) export function startSyncService(startWithSync = false) { LOG(`Starting sync service`, syncState.syncInterval, startWithSync) if (syncState.syncInterval > 0) { startSyncTimer(startWithSync ? 5 : syncState.syncInterval) // first sync 5s after load //? good UX } } let timer: NodeJS.Timeout function startSyncTimer(intervalSeconds: number) { if (timer) throw ERROR(`A syncTimer is already running`, { intervalSeconds }) if (intervalSeconds < 1) throw ERROR(`intervalSeconds too small`, intervalSeconds) // if (timer) return // ? clearTimeout(timer) timer = setTimeout(() => { timer = null if (syncState.lastNonIdleTime != null) { const idleTime = differenceInSeconds(new Date(), syncState.lastNonIdleTime) DEBUG('idle enought for sync?', { idleTime, last: syncState.lastNonIdleTime }) if (idleTime <= (syncState.minIdle ?? DEFAULT_MIN_IDLE_SECONDS)) { return startSyncTimer(Math.max(1, (syncState.minIdle ?? DEFAULT_MIN_IDLE_SECONDS) - idleTime + 1)) } } doSync() }, 1000 * intervalSeconds) } export function stopSyncService() { LOG(`Stopping sync service`) if (timer) clearTimeout(timer) timer = null } export function triggerSync() { LOG(`[triggerSync]`) clearTimeout(timer) timer = null void doSync() } export async function doSync({ share, sub }: { share?: string, sub?: string } = {}) { if (syncState.syncing) { // if (share || sub) throw ERROR(`A sync is already running`, { share, state: { ...syncState } }) // we still wanna run it after - maybe it's not marked as autosync // probably timer fired while doing manual (single pub/sub) sync => we wait for that before doing the full sync LOG('[doSync] Already syncing (probably manually triggered), waiting 1s', { share, sub }) timer = setTimeout(() => doSync({ share, sub }), 1000) return } if (!isOnline() && !(share || sub)) { // HACK: if manually triggered, do it either way!? LOG('[doSync] Offline - skipping sync', { share, sub }) return } // Flush any pending block content updates before syncing await flushAllPendingBlockContent() DEBUG('[Sync] Starting') const agent = useAgent() runInAction(() => { syncState.syncing = [] syncState.errors = [] }) try { const db = getApplogDB() if (!db) throw ERROR(`[Sync] No DB!`, db) if (sub) { await doPull(db, [agent.subscriptionsMap.get(sub)], false) } else if (share) { await doPush(db, [agent.sharesMap.get(share)], false) } else { const promises = [doPull(db, agent.subscriptions, true)] if (!agent.hasStorageSetup) { DEBUG(`No storage setup - skipping push`) } else { promises.push(doPush(db, agent.shares, true)) } await Promise.all(promises) } } catch (error) { runInAction(() => { ERROR('[Sync] failed to sync', error) syncState.errors.push({ error }) }) } finally { runInAction(() => syncState.syncing = null) if (!sub && !share && syncState.syncInterval > 0) { startSyncTimer(syncState.syncInterval) } } } async function doPull(thread: AppThread, subscriptions: ISubscription[], onlyIfAuto: boolean) { const agent = useAgent() for (const sub of subscriptions) { if (onlyIfAuto && !sub.autopull) continue DEBUG('[Sync] Pulling', sub) const syncStateEntry = { sub } runInAction(() => syncState.syncing.push(syncStateEntry)) await agent.updateSub(sub.id, { lastPullAttempt: dateNowIso() }) try { // Pull const pullData = await retrievePubToThread(thread, sub.id, { lastCID: sub.lastCID, subID: sub.id }, getApplogDB()) await updateSubFromPullData(pullData, sub.id) } catch (error) { runInAction(() => { ERROR('[Sync] failed to retrieve', sub, error) syncState.errors.push({ sub, error }) }) } finally { runInAction(() => pull(syncState.syncing, syncStateEntry)) } } } async function doPush(thread: Thread, shares: IShare[], onlyIfAuto: boolean) { await Promise.all(shares.map(async (share) => { if (onlyIfAuto && !share.autopush) return DEBUG('[Sync] Pushing', share) const syncStateEntry = { share } runInAction(() => syncState.syncing.push(syncStateEntry)) try { await pushShare(thread, share) } catch (error) { runInAction(() => { ERROR('[Sync] failed to publish', share, error) syncState.errors.push({ share, error }) }) } finally { runInAction(() => pull(syncState.syncing, syncStateEntry)) } })) }