Source: index.js

/* global: localStorage */
const JSData = require('js-data')
const Adapter = require('js-data-adapter')
const guid = require('mout/random/guid')

const {
  Query,
  utils
} = JSData

const {
  addHiddenPropsToTarget,
  deepMixIn,
  extend,
  fillIn,
  forOwn,
  fromJson,
  get,
  isArray,
  isUndefined,
  reject,
  set,
  toJson
} = utils

function isValidString (value) {
  return (value != null && value !== '')
}
function join (items, separator) {
  separator || (separator = '')
  return items.filter(isValidString).join(separator)
}
function makePath (...args) {
  let result = join(args, '/')
  return result.replace(/([^:\/]|^)\/{2,}/g, '$1/')
}
let queue = []
let taskInProcess = false

function enqueue (task) {
  queue.push(task)
}

function dequeue () {
  if (queue.length && !taskInProcess) {
    taskInProcess = true
    queue[0]()
  }
}

function queueTask (task) {
  if (!queue.length) {
    enqueue(task)
    dequeue()
  } else {
    enqueue(task)
  }
}

function createTask (fn) {
  return new Promise(fn).then(function (result) {
    taskInProcess = false
    queue.shift()
    setTimeout(dequeue, 0)
    return result
  }, function (err) {
    taskInProcess = false
    queue.shift()
    setTimeout(dequeue, 0)
    return reject(err)
  })
}

const __super__ = Adapter.prototype

const DEFAULTS = {
  /**
   * TODO
   *
   * @name LocalStorageAdapter#basePath
   * @type {string}
   */
  basePath: '',

  /**
   * TODO
   *
   * @name LocalStorageAdapter#debug
   * @type {boolean}
   * @default false
   */
  debug: false,

  /**
   * TODO
   *
   * @name LocalStorageAdapter#storage
   * @type {Object}
   * @default localStorage
   */
  storage: localStorage
}

/**
 * LocalStorageAdapter class.
 *
 * @example
 * import {DataStore} from 'js-data'
 * import LocalStorageAdapter from 'js-data-localstorage'
 * const store = new DataStore()
 * const adapter = new LocalStorageAdapter()
 * store.registerAdapter('ls', adapter, { 'default': true })
 *
 * @class LocalStorageAdapter
 * @param {Object} [opts] Configuration opts.
 * @param {string} [opts.basePath=''] TODO
 * @param {boolean} [opts.debug=false] TODO
 * @param {Object} [opts.storeage=localStorage] TODO
 */
function LocalStorageAdapter (opts) {
  const self = this
  opts || (opts = {})
  fillIn(opts, DEFAULTS)
  Adapter.call(self, opts)
}

// Setup prototype inheritance from Adapter
LocalStorageAdapter.prototype = Object.create(Adapter.prototype, {
  constructor: {
    value: LocalStorageAdapter,
    enumerable: false,
    writable: true,
    configurable: true
  }
})

Object.defineProperty(LocalStorageAdapter, '__super__', {
  configurable: true,
  value: Adapter
})

/**
 * Alternative to ES6 class syntax for extending `LocalStorageAdapter`.
 *
 * @name LocalStorageAdapter.extend
 * @method
 * @param {Object} [instanceProps] Properties that will be added to the
 * prototype of the subclass.
 * @param {Object} [classProps] Properties that will be added as static
 * properties to the subclass itself.
 * @return {Object} Subclass of `LocalStorageAdapter`.
 */
LocalStorageAdapter.extend = extend

addHiddenPropsToTarget(LocalStorageAdapter.prototype, {
  _createHelper (mapper, props, opts) {
    const self = this
    const _props = {}
    const relationFields = mapper.relationFields || []
    forOwn(props, function (value, key) {
      if (relationFields.indexOf(key) === -1) {
        _props[key] = value
      }
    })
    const id = get(_props, mapper.idAttribute) || guid()
    set(_props, mapper.idAttribute, id)
    const key = self.getIdPath(mapper, opts, id)

    // Create the record
    // TODO: Create related records when the "with" option is provided
    self.storage.setItem(key, toJson(_props))
    self.ensureId(id, mapper, opts)
    return fromJson(self.storage.getItem(key))
  },

  /**
   * Create a new record. Internal method used by Adapter#create.
   *
   * @name LocalStorageAdapter#_create
   * @method
   * @private
   * @param {Object} mapper The mapper.
   * @param {Object} props The record to be created.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _create (mapper, props, opts) {
    const self = this
    return new Promise(function (resolve) {
      return resolve([self._createHelper(mapper, props, opts), {}])
    })
  },

  /**
   * Create multiple records in a single batch. Internal method used by
   * Adapter#createMany.
   *
   * @name LocalStorageAdapter#_createMany
   * @method
   * @private
   * @param {Object} mapper The mapper.
   * @param {Object} props The records to be created.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _createMany (mapper, props, opts) {
    const self = this
    return new Promise(function (resolve) {
      props || (props = [])
      return resolve([props.map(function (_props) {
        return self._createHelper(mapper, _props, opts)
      }), {}])
    })
  },

  /**
   * Destroy the record with the given primary key. Internal method used by
   * Adapter#destroy.
   *
   * @name LocalStorageAdapter#_destroy
   * @method
   * @private
   * @param {Object} mapper The mapper.
   * @param {(string|number)} id Primary key of the record to destroy.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _destroy (mapper, id, opts) {
    const self = this
    return new Promise(function (resolve) {
      self.storage.removeItem(self.getIdPath(mapper, opts, id))
      self.removeId(id, mapper, opts)
      return resolve([undefined, {}])
    })
  },

  /**
   * Destroy the records that match the selection query. Internal method used by
   * Adapter#destroyAll.
   *
   * @name LocalStorageAdapter#_destroyAll
   * @method
   * @private
   * @param {Object} mapper the mapper.
   * @param {Object} [query] Selection query.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _destroyAll (mapper, query, opts) {
    const self = this
    return self._findAll(mapper, query).then(function (results) {
      let [records] = results
      const idAttribute = mapper.idAttribute
      // Gather IDs of records to be destroyed
      const ids = records.map(function (record) {
        return get(record, idAttribute)
      })
      // Destroy each record
      ids.forEach(function (id) {
        self.storage.removeItem(self.getIdPath(mapper, opts, id))
      })
      self.removeId(ids, mapper, opts)
      return [undefined, {}]
    })
  },

  /**
   * Retrieve the record with the given primary key. Internal method used by
   * Adapter#find.
   *
   * @name LocalStorageAdapter#_find
   * @method
   * @private
   * @param {Object} mapper The mapper.
   * @param {(string|number)} id Primary key of the record to retrieve.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _find (mapper, id, opts) {
    const self = this
    return new Promise(function (resolve) {
      const key = self.getIdPath(mapper, opts, id)
      const record = self.storage.getItem(key)
      return resolve([record ? fromJson(record) : undefined, {}])
    })
  },

  /**
   * Retrieve the records that match the selection query. Internal method used
   * by Adapter#findAll.
   *
   * @name LocalStorageAdapter#_findAll
   * @method
   * @private
   * @param {Object} mapper The mapper.
   * @param {Object} query Selection query.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _findAll (mapper, query, opts) {
    const self = this
    query || (query = {})
    return new Promise(function (resolve) {
      // Load all records into memory...
      let records = []
      const ids = self.getIds(mapper, opts)
      forOwn(ids, function (value, id) {
        const json = self.storage.getItem(self.getIdPath(mapper, opts, id))
        if (json) {
          records.push(fromJson(json))
        }
      })
      const _query = new Query({
        index: {
          getAll () {
            return records
          }
        }
      })
      return resolve([_query.filter(query).run(), {}])
    })
  },

  /**
   * Apply the given update to the record with the specified primary key.
   * Internal method used by Adapter#update.
   *
   * @name LocalStorageAdapter#_update
   * @method
   * @private
   * @param {Object} mapper The mapper.
   * @param {(string|number)} id The primary key of the record to be updated.
   * @param {Object} props The update to apply to the record.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _update (mapper, id, props, opts) {
    const self = this
    props || (props = {})
    return new Promise(function (resolve, reject) {
      const key = self.getIdPath(mapper, opts, id)
      let record = self.storage.getItem(key)
      if (!record) {
        return reject(new Error('Not Found'))
      }
      record = fromJson(record)
      deepMixIn(record, props)
      self.storage.setItem(key, toJson(record))
      return resolve([record, {}])
    })
  },

  /**
   * Apply the given update to all records that match the selection query.
   * Internal method used by Adapter#updateAll.
   *
   * @name LocalStorageAdapter#_updateAll
   * @method
   * @private
   * @param {Object} mapper The mapper.
   * @param {Object} props The update to apply to the selected records.
   * @param {Object} [query] Selection query.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _updateAll (mapper, props, query, opts) {
    const self = this
    const idAttribute = mapper.idAttribute
    return self._findAll(mapper, query, opts).then(function (results) {
      let [records] = results
      records.forEach(function (record) {
        record || (record = {})
        const id = get(record, idAttribute)
        const key = self.getIdPath(mapper, opts, id)
        deepMixIn(record, props)
        self.storage.setItem(key, toJson(record))
      })
      return [records, {}]
    })
  },

  /**
   * Update the given records in a single batch. Internal method used by
   * Adapter#updateMany.
   *
   * @name LocalStorageAdapter#updateMany
   * @method
   * @private
   * @param {Object} mapper The mapper.
   * @param {Object[]} records The records to update.
   * @param {Object} [opts] Configuration options.
   * @return {Promise}
   */
  _updateMany (mapper, records, opts) {
    const self = this
    records || (records = [])
    return new Promise(function (resolve) {
      const updatedRecords = []
      const idAttribute = mapper.idAttribute
      records.forEach(function (record) {
        if (!record) {
          return
        }
        const id = get(record, idAttribute)
        if (isUndefined(id)) {
          return
        }
        const key = self.getIdPath(mapper, opts, id)
        let json = self.storage.getItem(key)
        if (!json) {
          return
        }
        const existingRecord = fromJson(json)
        deepMixIn(existingRecord, record)
        self.storage.setItem(key, toJson(existingRecord))
        updatedRecords.push(existingRecord)
      })
      return resolve([records, {}])
    })
  },

  create (mapper, props, opts) {
    const self = this
    return createTask(function (success, failure) {
      queueTask(function () {
        __super__.create.call(self, mapper, props, opts).then(success, failure)
      })
    })
  },

  createMany (mapper, props, opts) {
    const self = this
    return createTask(function (success, failure) {
      queueTask(function () {
        __super__.createMany.call(self, mapper, props, opts).then(success, failure)
      })
    })
  },

  destroy (mapper, id, opts) {
    const self = this
    return createTask(function (success, failure) {
      queueTask(function () {
        __super__.destroy.call(self, mapper, id, opts).then(success, failure)
      })
    })
  },

  destroyAll (mapper, query, opts) {
    const self = this
    return createTask(function (success, failure) {
      queueTask(function () {
        __super__.destroyAll.call(self, mapper, query, opts).then(success, failure)
      })
    })
  },

  /**
   * TODO
   *
   * @name LocalStorageAdapter#ensureId
   * @method
   */
  ensureId (id, mapper, opts) {
    const ids = this.getIds(mapper, opts)
    if (isArray(id)) {
      if (!id.length) {
        return
      }
      id.forEach(function (_id) {
        ids[_id] = 1
      })
    } else {
      ids[id] = 1
    }
    this.saveKeys(ids, mapper, opts)
  },

  /**
   * TODO
   *
   * @name LocalStorageAdapter#getPath
   * @method
   */
  getPath (mapper, opts) {
    opts = opts || {}
    return makePath(opts.basePath === undefined ? (mapper.basePath === undefined ? this.basePath : mapper.basePath) : opts.basePath, mapper.name)
  },

  /**
   * TODO
   *
   * @name LocalStorageAdapter#getIdPath
   * @method
   */
  getIdPath (mapper, opts, id) {
    opts = opts || {}
    return makePath(opts.basePath || this.basePath || mapper.basePath, mapper.endpoint, id)
  },

  /**
   * TODO
   *
   * @name LocalStorageAdapter#getIds
   * @method
   */
  getIds (mapper, opts) {
    let ids
    const idsPath = this.getPath(mapper, opts)
    const idsJson = this.storage.getItem(idsPath)
    if (idsJson) {
      ids = fromJson(idsJson)
    } else {
      ids = {}
    }
    return ids
  },

  /**
   * TODO
   *
   * @name LocalStorageAdapter#removeId
   * @method
   */
  removeId (id, mapper, opts) {
    const ids = this.getIds(mapper, opts)
    if (isArray(id)) {
      if (!id.length) {
        return
      }
      id.forEach(function (_id) {
        delete ids[_id]
      })
    } else {
      delete ids[id]
    }
    this.saveKeys(ids, mapper, opts)
  },

  /**
   * TODO
   *
   * @name LocalStorageAdapter#saveKeys
   * @method
   */
  saveKeys (ids, mapper, opts) {
    ids = ids || {}
    const idsPath = this.getPath(mapper, opts)
    if (Object.keys(ids).length) {
      this.storage.setItem(idsPath, toJson(ids))
    } else {
      this.storage.removeItem(idsPath)
    }
  },

  update (mapper, id, props, opts) {
    const self = this
    return createTask(function (success, failure) {
      queueTask(function () {
        __super__.update.call(self, mapper, id, props, opts).then(success, failure)
      })
    })
  },

  updateAll (mapper, props, query, opts) {
    const self = this
    return createTask(function (success, failure) {
      queueTask(function () {
        __super__.updateAll.call(self, mapper, props, query, opts).then(success, failure)
      })
    })
  },

  updateMany (mapper, records, opts) {
    const self = this
    return createTask(function (success, failure) {
      queueTask(function () {
        __super__.updateMany.call(self, mapper, records, opts).then(success, failure)
      })
    })
  }
})

/**
 * Details of the current version of the `js-data-localstorage` module.
 *
 * @name LocalStorageAdapter.version
 * @type {Object}
 * @property {string} version.full The full semver value.
 * @property {number} version.major The major version number.
 * @property {number} version.minor The minor version number.
 * @property {number} version.patch The patch version number.
 * @property {(string|boolean)} version.alpha The alpha version value,
 * otherwise `false` if the current version is not alpha.
 * @property {(string|boolean)} version.beta The beta version value,
 * otherwise `false` if the current version is not beta.
 */
LocalStorageAdapter.version = {
  full: '<%= pkg.version %>',
  major: parseInt('<%= major %>', 10),
  minor: parseInt('<%= minor %>', 10),
  patch: parseInt('<%= patch %>', 10),
  alpha: '<%= alpha %>' !== 'false' ? '<%= alpha %>' : false,
  beta: '<%= beta %>' !== 'false' ? '<%= beta %>' : false
}

/**
 * Registered as `js-data-localstorage` in NPM and Bower.
 *
 * __Script tag__:
 * ```javascript
 * window.LocalStorageAdapter
 * ```
 * __CommonJS__:
 * ```javascript
 * var LocalStorageAdapter = require('js-data-localstorage')
 * ```
 * __ES6 Modules__:
 * ```javascript
 * import LocalStorageAdapter from 'js-data-localstorage'
 * ```
 * __AMD__:
 * ```javascript
 * define('myApp', ['js-data-localstorage'], function (LocalStorageAdapter) { ... })
 * ```
 *
 * @module js-data-localstorage
 */

module.exports = LocalStorageAdapter