minimongo
Version:
Client-side mongo database with server sync over http
555 lines (493 loc) • 17.9 kB
text/typescript
import _ from "lodash"
import { processFind } from "./utils"
import * as utils from "./utils"
import {
Doc,
Item,
MinimongoBaseCollection,
MinimongoCollection,
MinimongoCollectionFindOneOptions,
MinimongoDb,
MinimongoLocalCollection
} from "./types"
import { MinimongoLocalDb } from "."
/** Bridges a local and remote database, querying from the local first and then
* getting the remote. Also uploads changes from local to remote.
*/
export default class HybridDb implements MinimongoDb {
localDb: MinimongoLocalDb
remoteDb: MinimongoDb
collections: { [collectionName: string]: HybridCollection<any> }
constructor(localDb: MinimongoLocalDb, remoteDb: MinimongoDb) {
this.localDb = localDb
this.remoteDb = remoteDb
this.collections = {}
}
addCollection(name: string, success?: () => void, error?: (error: any) => void): void
addCollection(name: string, options?: HybridCollectionOptions, success?: any, error?: any): void
addCollection(name: string, options?: any, success?: any, error?: any) {
// Shift options over if not present
if (_.isFunction(options)) {
;[options, success, error] = [{}, options, success]
}
const collection = new HybridCollection(name, this.localDb![name], this.remoteDb![name], options)
this[name] = collection
this.collections[name] = collection
if (success != null) {
return success()
}
}
removeCollection(name: any, success: any, error: any) {
delete this[name]
delete this.collections[name]
if (success != null) {
return success()
}
}
/** Upload any changes to the remote database */
upload(success: () => void, error: (err: any) => void): void
upload(): Promise<void>
upload(success?: () => void, error?: (err: any) => void): void | Promise<void> {
if (success == null) {
return new Promise((resolve, reject) => {
return this.upload(resolve, reject)
})
}
const cols = Object.values(this.collections)
function uploadCols(cols: HybridCollection<any>[], success: any, error: any) {
const col = _.first(cols)
if (col) {
col.upload(
() => uploadCols(_.tail(cols), success, error),
(err: any) => error(err)
)
} else {
success()
}
}
return uploadCols(cols, success, error)
}
getCollectionNames() {
return _.keys(this.collections)
}
}
export interface HybridCollectionOptions {
/** Cache find results in local db */
cacheFind?: boolean
/** Cache findOne results in local db */
cacheFindOne?: boolean
/** Return interim results from local db while waiting for remote db. Return again if different */
interim?: boolean
/** Set to ms to timeout in for remote calls */
timeout?: number
/** Use local results if the remote find fails. Only applies if interim is false. */
useLocalOnRemoteError?: boolean
/** true to return `findOne` results if any matching result is found in the local database. Useful for documents that change rarely. */
shortcut?: boolean
/** Compare function to sort upserts sent to server */
sortUpserts: (a: Doc, b: Doc) => number
}
export class HybridCollection<T extends Doc> implements MinimongoBaseCollection<T> {
name: string
localCol: MinimongoLocalCollection<any>
remoteCol: MinimongoCollection<any>
options: any
// Options includes
constructor(
name: string,
localCol: MinimongoLocalCollection<T>,
remoteCol: MinimongoCollection<T>,
options?: HybridCollectionOptions
) {
this.name = name
this.localCol = localCol
this.remoteCol = remoteCol
// Default options
this.options = options || {}
_.defaults(this.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: any, options = {}) {
return {
fetch: (success?: any, error?: any) => {
return this._findFetch(selector, options, success, error)
}
}
}
// Finds one row.
findOne(selector: any, options?: MinimongoCollectionFindOneOptions): Promise<T | null>
findOne(
selector: any,
options: MinimongoCollectionFindOneOptions,
success: (item: T | null) => void,
error: (err: any) => void
): void
findOne(selector: any, success: (item: T | null) => void, error: (err: any) => void): void
findOne(selector: any, options?: any, success?: any, error?: any): any {
if (_.isFunction(options)) {
;[options, success, error] = [{}, options, success]
}
options = options || {}
// If promise case
if (success == null) {
return new Promise((resolve, reject) => {
this.findOne(selector, { ...options, interim: false }, resolve, reject)
})
}
// Merge options
_.defaults(options, this.options)
// Happens after initial find
const step2 = (localDoc: any) => {
const findOptions = { ...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
}
return this.find(selector, findOptions).fetch(function (data: any) {
// Return first entry or null
if (data.length > 0) {
// Check that different from existing
if (JSON.stringify(localDoc) != JSON.stringify(data[0])) {
return success(data[0])
}
} else {
// If nothing found, always report it, as interim find doesn't return null
return success(null)
}
}, error)
}
// If interim or shortcut, get local first
if (options.interim || options.shortcut) {
return this.localCol.findOne(
selector,
options,
function (localDoc: any) {
// If found, return
if (localDoc) {
success(JSON.parse(JSON.stringify(localDoc)))
// If shortcut, we're done
if (options.shortcut) {
return
}
}
return step2(localDoc)
},
error
)
} else {
return step2(null)
}
}
_findFetch(selector: any, options: any, success: any, error: any): any {
// If promise case
if (success == null) {
// Implies interim false (since promises cannot resolve twice)
return new Promise((resolve, reject) => {
this._findFetch(selector, { ...options, interim: false }, resolve, reject)
})
}
// Merge options
_.defaults(options, this.options)
// Get pending removes and upserts immediately to avoid odd race conditions
this.localCol.pendingUpserts!((upserts: any) => {
this.localCol.pendingRemoves!((removes: any) => {
const step2 = (localData: any) => {
// Setup remote options
const remoteOptions = { ...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
let timer: any = null
let timedOut = false
const remoteSuccess = (remoteData: any) => {
// Cancel timer
if (timer) {
clearTimeout(timer)
}
// Ignore if timed out, caching asynchronously
if (timedOut) {
if (options.cacheFind) {
this.localCol.cache(remoteData, selector, options, function () {}, error)
}
return
}
if (options.cacheFind) {
// Cache locally
const cacheSuccess = () => {
// Get local data again
function localSuccess2(localData2: any) {
// Check if different or not interim
if (!options.interim || JSON.stringify(localData) != JSON.stringify(localData2)) {
// Send again
return success(localData2)
}
}
return this.localCol.find(selector, options).fetch(localSuccess2, error)
}
// Exclude any recent upserts/removes to prevent race condition
const cacheOptions = _.extend({}, options, {
exclude: removes.concat(_.map(upserts, (u: any) => u.doc._id))
})
return this.localCol.cache(remoteData, selector, cacheOptions, cacheSuccess, error)
} else {
// Remove local remotes
let data = remoteData
if (removes.length > 0) {
const removesMap = _.fromPairs(_.map(removes, (id: any) => [id, id]))
data = _.filter(remoteData, (doc: any) => !_.has(removesMap, doc._id))
}
// Add upserts
if (upserts.length > 0) {
// Remove upserts from data
const upsertsMap = _.fromPairs(
_.zip(
_.map(upserts, (u: any) => u.doc._id),
_.map(upserts, (u: any) => u.doc._id)
)
)
data = _.filter(data, (doc: any) => !_.has(upsertsMap, doc._id))
// Add upserts
data = data.concat(_.map(upserts, "doc"))
// Refilter/sort/limit
data = processFind(data, selector, options)
}
// Check if different or not interim
if (!options.interim || JSON.stringify(localData) != JSON.stringify(data)) {
// Send again
return success(data)
}
}
}
const remoteError = (err: any) => {
// Cancel timer
if (timer) {
clearTimeout(timer)
}
if (timedOut) {
return
}
// If no interim, do local find
if (!options.interim) {
if (options.useLocalOnRemoteError) {
return success(localData)
} else {
if (error) {
return 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 (!options.interim) {
if (options.useLocalOnRemoteError) {
return this.localCol.find(selector, options).fetch(success, error)
} else {
if (error) {
return error(new Error("Remote timed out"))
}
}
} else {
// Otherwise do nothing
return
}
}, options.timeout)
}
return this.remoteCol.find(selector, remoteOptions).fetch(remoteSuccess, remoteError)
}
function localSuccess(localData: any) {
// If interim, return data immediately
if (options.interim) {
success(localData)
}
return step2(localData)
}
// Always get local data first
return this.localCol.find(selector, options).fetch(localSuccess, error)
}, error)
}, error)
}
upsert(doc: T): Promise<T | null>
upsert(doc: T, base: T | null | undefined): Promise<T | null>
upsert(docs: T[]): Promise<(T | null)[]>
upsert(docs: T[], bases: (T | null | undefined)[]): Promise<(T | null)[]>
upsert(doc: T, success: (doc: T | null) => void, error: (err: any) => void): void
upsert(doc: T, base: T | null | undefined, success: (doc: T | null) => void, error: (err: any) => void): void
upsert(docs: T[], success: (docs: (T | null)[]) => void, error: (err: any) => void): void
upsert(
docs: T[],
bases: (T | null | undefined)[],
success: (item: (T | null)[]) => void,
error: (err: any) => void
): void
upsert(docs: any, bases?: any, success?: any, error?: any): any {
if (!success && !_.isFunction(bases)) {
return new Promise((resolve, reject) => {
this.upsert(
docs,
bases,
resolve,
reject
)
})
}
return this.localCol.upsert(docs, bases, success, error)
}
remove(id: any): Promise<void>
remove(id: any, success: () => void, error: (err: any) => void): void
remove(id: any, success?: () => void, error?: (err: any) => void): any {
if (!success) {
return new Promise<void>((resolve, reject) => {
this.remove(id, resolve, reject)
})
}
return this.localCol.remove(
id,
function () {
if (success != null) {
return success()
}
},
error!
)
}
upload(success: () => void, error: (err: any) => void) {
const uploadUpserts = (upserts: Item<T>[], success: () => void, error: (err: any) => void): void => {
const upsert = _.first(upserts)
if (upsert) {
// Handle case if identical doc and base https://github.com/mWater/minimongo/issues/89
if (JSON.stringify(upsert.doc) === JSON.stringify(upsert.base)) {
return this.localCol.resolveUpserts([upsert], () => uploadUpserts(_.tail(upserts), success, error), error)
}
return this.remoteCol.upsert(
upsert.doc,
upsert.base,
(remoteDoc: any) => {
return this.localCol.resolveUpserts(
[upsert],
() => {
// Cache new value if present
if (remoteDoc) {
return this.localCol.cacheOne(remoteDoc, () => uploadUpserts(_.tail(upserts), success, error), error)
} else {
// Remove local
return this.localCol.remove(
upsert.doc._id!,
() => {
// Resolve remove
return this.localCol.resolveRemove(
upsert.doc._id!,
() => uploadUpserts(_.tail(upserts), success, error),
error
)
},
error
)
}
},
error
)
},
(err) => {
// If 410 error or 403, remove document
if (err.status === 410 || err.status === 403) {
return this.localCol.remove(
upsert.doc._id!,
() => {
// Resolve remove
return this.localCol.resolveRemove(
upsert.doc._id!,
function () {
// Continue if was 410
if (err.status === 410) {
return uploadUpserts(_.tail(upserts), success, error)
} else {
return error(err)
}
},
error
)
},
error
)
} else {
return error(err)
}
}
)
} else {
return success()
}
}
const uploadRemoves = (removes: string[], success: () => void, error: (error: any) => void): void => {
const remove = _.first(removes)
if (remove) {
return this.remoteCol.remove(
remove,
() => {
return this.localCol.resolveRemove(remove, () => uploadRemoves(_.tail(removes), success, error), error)
},
(err) => {
// If 403 or 410, remove document
if (err.status === 410 || err.status === 403) {
return this.localCol.resolveRemove(
remove,
function () {
// Continue if was 410
if (err.status === 410) {
return uploadRemoves(_.tail(removes), success, error)
} else {
return error(err)
}
},
error
)
} else {
return error(err)
}
}
)
} else {
success()
}
}
// Get pending upserts
this.localCol.pendingUpserts((upserts: any) => {
// Sort upserts if sort defined
if (this.options.sortUpserts) {
upserts.sort((u1: any, u2: any) => this.options.sortUpserts(u1.doc, u2.doc))
}
return uploadUpserts(
upserts,
() => {
return this.localCol.pendingRemoves((removes: any) => uploadRemoves(removes, success, error), error)
},
error
)
}, error)
}
}