mbland/url-pointers

View on GitHub
lib/redis/index.js

Summary

Maintainability
A
3 hrs
Test Coverage
'use strict'

var PromiseHelper = require('./promise-helper')
var ListHelper = require('./list-helper')
var SearchHelper = require('./search-helper')
var AutocompleteIndexer = require('./autocomplete-indexer')
var TargetIndexer = require('./target-indexer')
var Keys = require('./keys')

module.exports = RedisClient

function RedisClient(clientImpl, config, getTimestamp) {
  this.impl = clientImpl
  this.rangeSize = (config || {}).REDIS_RANGE_SIZE || 25
  this.getTimestamp = getTimestamp || (() => new Date().getTime())
  this.indexers = [
    new AutocompleteIndexer(this.impl),
    new TargetIndexer(this.impl)
  ]
}

RedisClient.prototype.fetchLinkData = function(links) {
  return Promise.all(links.map(link => {
    return this.getLink(link).then(linkData => {
      linkData.link = link
      return linkData
    })
  }))
}

RedisClient.prototype.userExists = function(userId) {
  return PromiseHelper.expect(1, done => this.impl.exists(userId, done))
}

RedisClient.prototype.findOrCreateUser = function(userId) {
  return this.userExists(userId)
    .then(exists => {
      if (exists) {
        return false
      }
      return PromiseHelper.expect(1, done => this.impl.lpush(userId, '', done))
    })
}

RedisClient.prototype.getLink = function(link) {
  return new Promise((resolve, reject) => {
    this.impl.hgetall(link, (err, linkData) => {
      if (err) {
        return reject(err)
      } else if (linkData && linkData.clicks) {
        linkData.clicks = parseInt(linkData.clicks)
      }
      resolve(linkData)
    })
  })
}

function getShortLinksFromTargetLinks(redisClient, targetKeys) {
  return Promise.all(targetKeys.map(targetKey =>
    getShortLinksFromTargetLink(redisClient, targetKey)
  )).then(shortLinks =>
    targetKeys.reduce((results, targetIndexKey, i) => {
      results[Keys.getLinkFromTargetIndexKey(targetIndexKey)] = shortLinks[i]
      return results
    }, {})
  )
}

function getShortLinksFromTargetLink(redisClient, targetLink) {
  return new Promise((resolve, reject) => {
    redisClient.impl.smembers(targetLink, (err, shortLinks) => {
      return err ? reject(err) : resolve(shortLinks.sort())
    })
  })
}

RedisClient.prototype.searchShortLinks = function(searchString) {
  var searchHelper = new SearchHelper(this.impl, searchString,
    Keys.SHORT_LINK_PREFIX)
  return searchHelper.scan().then(links => this.fetchLinkData(links.sort()))
}

RedisClient.prototype.searchTargetLinks = function(searchString) {
  var searchHelper = new SearchHelper(this.impl, searchString,
    Keys.TARGET_LINK_INDEX_PREFIX)
  return searchHelper.scan().then(links =>
    getShortLinksFromTargetLinks(this, links))
}

RedisClient.prototype.recordAccess = function(link) {
  return new Promise((resolve, reject) => {
    this.impl.hincrby(link, 'clicks', 1, err => err ? reject(err) : resolve())
  })
}

RedisClient.prototype.addLinkToOwner = function(owner, link) {
  return new Promise((resolve, reject) => {
    this.impl.lpushx(owner, link, (err, result) => {
      err ? reject(err) : resolve(result !== 0)
    })
  })
}

RedisClient.prototype.removeLinkFromOwner = function(owner, link) {
  return new ListHelper(this.impl).removeItem(owner, link)
}

RedisClient.prototype.createLink = function(link, target, owner) {
  return new Promise((resolve, reject) => {
    var createdStamp = this.getTimestamp()
    const linkInfo = { target: target }
    this.impl.hsetnx(link, 'owner', owner, (err, result) => {
      if (err) {
        return reject(err)
      } else if (result === 0) {
        return resolve(false)
      }
      this.impl.hmset(link,
        'target', target,
        'created', createdStamp,
        'updated', createdStamp,
        'clicks', 0,
        err => {
          if (err) {
            return reject(new Error(link + ' created, ' +
              'but failed to set target, clicks, and timestamps: ' + err))
          }
          PromiseHelper.map(this.indexers, i => i.addLink(link, linkInfo))
          resolve(true)
        })
    })
  })
}

RedisClient.prototype.indexLink = function(link, linkInfo) {
  return PromiseHelper.map(this.indexers, i => i.addLink(link, linkInfo))
}

RedisClient.prototype.completeLink = function(prefix) {
  const MIN_LINK_PREFIX_SIZE = 3

  return prefix.length < MIN_LINK_PREFIX_SIZE ? Promise.resolve([]) :
    new AutocompleteIndexer(this.impl).completeString(prefix, this.rangeSize)
}

RedisClient.prototype.getLinksToTarget = function(target) {
  return new TargetIndexer(this.impl).getLinksToTarget(target)
}

RedisClient.prototype.getOwnedLinks = function(owner) {
  return new Promise((resolve, reject) => {
    this.impl.lrange(owner, 0, -1, (err, data) => {
      if (err) {
        return reject(err)
      }
      // Since the model is that users are created with the empty string as the
      // first list element, pop that before returning links.
      data.pop()
      resolve(data)
    })
  })
}

RedisClient.prototype.updateProperty = function(link, property, value) {
  return this.getLink(link)
    .then(linkData => {
      if (!linkData) {
        return Promise.resolve(false)
      }
      return PromiseHelper.expect('OK', done => {
        this.impl.hmset(link,
          property, value,
          'updated', this.getTimestamp(),
          done)
      })
    })
}

RedisClient.prototype.reindexLink = function(link, prevInfo, newInfo) {
  return PromiseHelper.map(this.indexers, i => {
    return i.shouldReindexLink(link, prevInfo, newInfo) === false ?
      Promise.resolve() :
      Promise.all([
        i.addLink(link, newInfo),
        i.removeLink(link, prevInfo)
      ])
  })
}

RedisClient.prototype.deleteLink = function(link) {
  return PromiseHelper.expect(1, done => this.impl.del(link, done))
}

RedisClient.prototype.deindexLink = function(link, linkInfo) {
  return PromiseHelper.map(this.indexers, i => i.removeLink(link, linkInfo))
}