cozy-labs/cozy-desktop

View on GitHub
core/syncstate.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * @module core/syncstate
 * @flow
 */

const autoBind = require('auto-bind')
const EventEmitter = require('events')
const deepDiff = require('deep-diff').diff

/*::
import type { SideName } from './side'

type UserActionStatus = 'Required'|'InProgress'
export type UserActionCommand =
  | 'retry'
  | 'skip'
  | 'create-conflict'
  | 'link-directories'
export type UserAlert = {
  seq: ?number,
  code: string,
  side: ?SideName,
  doc?: {
    docType: string,
    path: string,
  },
  links: ?{ self: string },
  status: UserActionStatus
}

type State = {
  offline: boolean,
  syncTargetSeq: number,
  syncCurrentSeq: number,
  remaining: number,
  buffering: boolean,
  syncing: boolean,
  localPrep: boolean,
  remotePrep: boolean,
  userAlerts: UserAlert[],
  errors: SyncError[]
}

export type SyncStatus =
  | 'buffering'
  | 'squashprepmerge'
  | 'offline'
  | 'syncing'
  | 'uptodate'
  | 'user-alert'
  | 'error'

export type SyncError = {|
  name: string,
  code: string,
|}
*/

const makeAlert = (
  err /*: Object */,
  seq /*: ?number */,
  side /*: ?SideName */
) /*: UserAlert */ => {
  const { doc } = err
  const links = err.links || (err.originalErr && err.originalErr.links)

  return {
    seq: err.seq || seq || null,
    status: 'Required',
    code: err.code,
    side: side || null,
    doc: doc || null,
    links: links || null
  }
}

const addAlert = (
  alerts /*: UserAlert[] */,
  newAlert /*: UserAlert */
) /*: UserAlert[] */ => {
  const existingAlert = alerts.find(alert => alert.code === newAlert.code)
  if (existingAlert) {
    existingAlert.status = 'Required'
    return alerts
  } else {
    return alerts.concat(newAlert)
  }
}

const updateAlert = (
  alerts /*: UserAlert[] */,
  alert /*: UserAlert */,
  status /*: UserActionStatus */
) /*: UserAlert[] */ => {
  return alerts.reduce((prev /*: UserAlert[] */, curr /*: UserAlert */) => {
    if (curr.code === alert.code) {
      return prev.concat({ ...alert, status })
    } else {
      return prev.concat(curr)
    }
  }, [])
}

const removeAlert = (
  alerts /*: UserAlert[] */,
  alert /*: UserAlert */
) /*: UserAlert[] */ => {
  return alerts.filter(a => a.code !== alert.code)
}

const makeError = (err /*: Object */) /*: SyncError */ => {
  const { name = '', code = '' } = err

  return {
    name,
    code
  }
}

const addError = (
  errors /*: SyncError[] */,
  newError /*: SyncError */
) /*: SyncError[] */ => {
  const existingError = errors.find(error => error.code === error.code)
  if (existingError) {
    return errors
  } else {
    return errors.concat(newError)
  }
}

module.exports = class SyncState extends EventEmitter {
  /*::
  state: State
  */

  constructor() {
    super()

    this.state = {
      offline: false,
      syncTargetSeq: -1,
      syncCurrentSeq: -1,
      remaining: 0,
      buffering: false,
      syncing: false,
      localPrep: false,
      remotePrep: false,
      userAlerts: [],
      errors: []
    }

    autoBind(this)
  }

  emitStatus() {
    const {
      offline,
      remaining,
      buffering,
      syncing,
      localPrep,
      remotePrep,
      userAlerts,
      errors
    } = this.state

    const status /*: SyncStatus */ =
      errors.length > 0
        ? 'error'
        : userAlerts.length > 0
        ? 'user-alert'
        : offline
        ? 'offline'
        : syncing
        ? 'syncing'
        : buffering
        ? 'buffering'
        : localPrep || remotePrep
        ? 'squashprepmerge'
        : 'uptodate'

    super.emit('sync-state', { status, remaining, userAlerts, errors })
  }

  update(newState /*: $Shape<State> */) {
    const { state } = this

    const syncCurrentSeq = newState.syncCurrentSeq || state.syncCurrentSeq
    const syncTargetSeq = newState.syncTargetSeq || state.syncTargetSeq
    const remaining =
      // If the current or target sequence have changed
      (syncCurrentSeq !== state.syncCurrentSeq ||
        syncTargetSeq !== state.syncTargetSeq) &&
      // And we've merged some changes already
      state.syncTargetSeq !== -1
        ? // If the sync process has been started at least once
          state.syncCurrentSeq !== -1
          ? Math.max(syncTargetSeq - syncCurrentSeq, 0)
          : // Else we're buffering changes to be synced
            state.remaining + 1
        : // Otherwise the remaining number of changes is still the same
          state.remaining

    const updatedUserAlerts = newState.userAlerts || state.userAlerts
    const userAlerts =
      newState.syncCurrentSeq != null
        ? updatedUserAlerts.reduce(
            (alerts /*: UserAlert[] */, alert /*: UserAlert */) => {
              if (alert.seq && alert.seq <= newState.syncCurrentSeq) {
                return alerts
              } else {
                return alerts.concat(alert)
              }
            },
            []
          )
        : updatedUserAlerts

    newState = {
      ...state,
      ...newState,
      remaining,
      userAlerts
    }

    const diff = deepDiff(state, newState)
    if (diff) {
      // Limit the number of events sent to the Electron window
      this.state = newState
      this.emitStatus()
    }
  }

  emit(name /*: string */, ...args /*: any[] */) /*: boolean */ {
    switch (name) {
      case 'online':
        this.update({ offline: false })
        break
      case 'offline':
        this.update({ offline: true })
        break
      case 'buffering-start':
        this.update({ buffering: true })
        break
      case 'buffering-end':
        this.update({ buffering: false })
        break
      case 'local-start':
        this.update({ localPrep: true })
        break
      case 'remote-start':
        this.update({ remotePrep: true })
        break
      case 'sync-start':
        this.update({
          localPrep: false,
          remotePrep: false,
          syncing: true
        })
        break
      case 'local-end':
        this.update({ localPrep: false })
        break
      case 'remote-end':
        this.update({ remotePrep: false })
        break
      case 'sync-end':
        this.update({ syncing: false })
        break
      case 'sync-target':
        if (typeof args[0] === 'number') {
          this.update({ syncTargetSeq: args[0] })
        }
        break
      case 'sync-current':
        if (typeof args[0] === 'number') {
          this.update({ syncCurrentSeq: args[0] })
        }
        break
      case 'Sync:fatal':
        this.update({
          errors: addError(this.state.errors, makeError(...args))
        })
        break
      case 'user-alert':
        this.update({
          userAlerts: addAlert(this.state.userAlerts, makeAlert(...args))
        })
        break
      case 'user-action-inprogress':
        this.update({
          userAlerts: updateAlert(
            this.state.userAlerts,
            makeAlert(args[0]),
            'InProgress'
          )
        })
        break
      case 'user-action-done':
        this.update({
          userAlerts: removeAlert(this.state.userAlerts, makeAlert(...args))
        })
        break
    }

    return super.emit(name, ...args)
  }
}