UNPKG

nqm-minimongo

Version:

Client-side mongo database with server sync over http

320 lines (275 loc) 9.95 kB
### Database which caches locally in a localDb but pulls results ultimately from a RemoteDb ### _ = require 'lodash' processFind = require('./utils').processFind utils = require('./utils') module.exports = class HybridDb constructor: (localDb, remoteDb) -> @localDb = localDb @remoteDb = remoteDb @collections = {} addCollection: (name, options, success, error) -> # Shift options over if not present if _.isFunction(options) [options, success, error] = [{}, options, success] collection = new HybridCollection(name, @localDb[name], @remoteDb[name], options) @[name] = collection @collections[name] = collection if success? then success() removeCollection: (name, success, error) -> delete @[name] delete @collections[name] if success? then success() upload: (success, error) -> cols = _.values(@collections) uploadCols = (cols, success, error) -> col = _.first(cols) if col col.upload(-> uploadCols(_.rest(cols), success, error) , (err) -> error(err)) else success() uploadCols(cols, success, error) class HybridCollection # Options includes constructor: (name, localCol, remoteCol, options) -> @name = name @localCol = localCol @remoteCol = remoteCol # Default options @options = options or {} _.defaults @options, { cacheFind: true # Cache find results in local db cacheFindOne: true # Cache findOne results in local db interim: true # Return interim results from local db while waiting for remote db. Return again if different useLocalOnRemoteError: true # Use local results if the remote find fails. Only applies if interim is false. shortcut: false # true to return `findOne` results if any matching result is found in the local database. Useful for documents that change rarely. timeout: 0 # Set to ms to timeout in for remote calls sortUpserts: null # Compare function to sort upserts sent to server } find: (selector, options = {}) -> return fetch: (success, error) => @_findFetch(selector, options, success, error) # Finds one row. findOne: (selector, options = {}, success, error) -> if _.isFunction(options) [options, success, error] = [{}, options, success] # Merge options _.defaults(options, @options) # Happens after initial find step2 = (localDoc) => findOptions = _.cloneDeep(options) findOptions.interim = false findOptions.cacheFind = options.cacheFindOne if selector._id findOptions.limit = 1 else # Without _id specified, interaction between local and remote changes is complex # For example, if the one result returned by remote is locally deleted, we have no fallback # So instead we do a find with no limit and then take the first result, which is very inefficient delete findOptions.limit @find(selector, findOptions).fetch (data) -> # Return first entry or null if data.length > 0 # Check that different from existing if not _.isEqual(localDoc, data[0]) success(data[0]) else # If nothing found, always report it, as interim find doesn't return null success(null) , error # If interim or shortcut, get local first if options.interim or options.shortcut @localCol.findOne selector, options, (localDoc) -> # If found, return if localDoc success(_.cloneDeep(localDoc)) # If shortcut, we're done if options.shortcut return step2(localDoc) , error else step2() _findFetch: (selector, options, success, error) -> # Merge options _.defaults(options, @options) step2 = (localData) => # Setup remote options remoteOptions = _.cloneDeep(options) # If caching, get all fields if options.cacheFind delete remoteOptions.fields # Setup timer variables timer = null timedOut = false remoteSuccess = (remoteData) => # Cancel timer if timer clearTimeout(timer) # Ignore if timed out, caching asynchronously if timedOut if options.cacheFind @localCol.cache(remoteData, selector, options, (->), error) return if options.cacheFind # Cache locally cacheSuccess = => # Get local data again localSuccess2 = (localData2) -> # Check if different if not _.isEqual(localData, localData2) # Send again success(localData2) @localCol.find(selector, options).fetch(localSuccess2, error) @localCol.cache(remoteData, selector, options, cacheSuccess, error) else # Remove local remotes data = remoteData @localCol.pendingRemoves (removes) => if removes.length > 0 removesMap = _.object(_.map(removes, (id) -> [id, id])) data = _.filter remoteData, (doc) -> return not _.has(removesMap, doc._id) # Add upserts @localCol.pendingUpserts (upserts) -> if upserts.length > 0 # Remove upserts from data upsertsMap = _.object(_.map(upserts, (u) -> u.doc._id), _.map(upserts, (u) -> u.doc._id)) data = _.filter data, (doc) -> return not _.has(upsertsMap, doc._id) # Add upserts data = data.concat(_.pluck(upserts, "doc")) # Refilter/sort/limit data = processFind(data, selector, options) # Check if different if not _.isEqual(localData, data) # Send again success(data) , error , error remoteError = (err) => # Cancel timer if timer clearTimeout(timer) if timedOut return # If no interim, do local find if not options.interim if options.useLocalOnRemoteError @localCol.find(selector, options).fetch(success, error) else if error then error(err) else # Otherwise do nothing return # Start timer if remote if options.timeout timer = setTimeout () => timer = null timedOut = true # If no interim, do local find if not options.interim if options.useLocalOnRemoteError @localCol.find(selector, options).fetch(success, error) else if error then error(new Error("Remote timed out")) else # Otherwise do nothing return , options.timeout @remoteCol.find(selector, remoteOptions).fetch(remoteSuccess, remoteError) # If interim, get local first if options.interim localSuccess = (localData) -> # Return data immediately success(localData) step2(localData) @localCol.find(selector, options).fetch(localSuccess, error) else step2() upsert: (docs, bases, success, error) -> @localCol.upsert(docs, bases, (result) -> # Bases is optional if _.isFunction(bases) success = bases success?(docs) , error) remove: (id, success, error) -> @localCol.remove(id, -> success() if success? , error) upload: (success, error) -> uploadUpserts = (upserts, success, error) => upsert = _.first(upserts) if upsert @remoteCol.upsert upsert.doc, upsert.base, (remoteDoc) => @localCol.resolveUpserts [upsert], => # Cache new value if present if remoteDoc @localCol.cacheOne remoteDoc, -> uploadUpserts(_.rest(upserts), success, error) , error else # Remove local @localCol.remove upsert.doc._id, => # Resolve remove @localCol.resolveRemove upsert.doc._id, -> uploadUpserts(_.rest(upserts), success, error) , error , error , error , (err) => # If 410 error or 403, remove document if err.status == 410 or err.status == 403 @localCol.remove upsert.doc._id, => # Resolve remove @localCol.resolveRemove upsert.doc._id, -> # Continue if was 410 if err.status == 410 uploadUpserts(_.rest(upserts), success, error) else error(err) , error , error else error(err) else success() uploadRemoves = (removes, success, error) => remove = _.first(removes) if remove @remoteCol.remove remove, => @localCol.resolveRemove remove, -> uploadRemoves(_.rest(removes), success, error) , error , (err) => # If 403 or 410, remove document if err.status == 410 or err.status == 403 @localCol.resolveRemove remove, -> # Continue if was 410 if err.status == 410 uploadRemoves(_.rest(removes), success, error) else error(err) , error else error(err) , error else success() # Get pending upserts @localCol.pendingUpserts (upserts) => # Sort upserts if sort defined if @options.sortUpserts upserts.sort(@options.sortUpserts) uploadUpserts upserts, => @localCol.pendingRemoves (removes) -> uploadRemoves(removes, success, error) , error , error , error