minimongo-sync
Version:
Client-side mongo database with server sync over http
329 lines (279 loc) • 10.9 kB
text/coffeescript
###
Database which caches locally in a localDb but pulls results
ultimately from a RemoteDb
###
_ = require 'lodash'
processFind = require('./utils').processFind
utils = require('./utils')
# Bridges a local and remote database, querying from the local first and then
# getting the remote. Also uploads changes from local to remote.
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)
getCollectionNames: -> _.keys(@collections)
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)
# Get pending removes and upserts immediately to avoid odd race conditions
@localCol.pendingUpserts (upserts) =>
@localCol.pendingRemoves (removes) =>
step2 = (localData) =>
# Setup remote options
remoteOptions = _.cloneDeep(options)
# If caching, get all fields
if options.cacheFind
delete remoteOptions.fields
# Add localData to options for remote find for quickfind protocol
remoteOptions.localData = localData
# 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 or not interim
if not options.interim or not _.isEqual(localData, localData2)
# Send again
success(localData2)
@localCol.find(selector, options).fetch(localSuccess2, error)
# Exclude any recent upserts/removes to prevent race condition
cacheOptions = _.extend({}, options, exclude: removes.concat(_.map(upserts, (u) => u.doc._id)))
@localCol.cache(remoteData, selector, cacheOptions, cacheSuccess, error)
else
# Remove local remotes
data = remoteData
if removes.length > 0
removesMap = _.object(_.map(removes, (id) -> [id, id]))
data = _.filter remoteData, (doc) ->
return not _.has(removesMap, doc._id)
# Add 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 or not interim
if not options.interim or not _.isEqual(localData, data)
# Send again
success(data)
remoteError = (err) =>
# Cancel timer
if timer
clearTimeout(timer)
if timedOut
return
# If no interim, do local find
if not options.interim
if options.useLocalOnRemoteError
success(localData)
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)
localSuccess = (localData) ->
# If interim, return data immediately
if options.interim
success(localData)
step2(localData)
# Always get local data first
@localCol.find(selector, options).fetch(localSuccess, error)
, error
, error
upsert: (docs, bases, success, error) ->
[items, success, error] = utils.regularizeUpsert(docs, bases, success, error)
@localCol.upsert(_.pluck(items, "doc"), _.pluck(items, "base"), (result) ->
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((u1, u2) => @options.sortUpserts(u1.doc, u2.doc))
uploadUpserts upserts, =>
@localCol.pendingRemoves (removes) ->
uploadRemoves(removes, success, error)
, error
, error
, error