minimongo
Version:
Client-side mongo database with server sync over http
491 lines (446 loc) • 13.3 kB
text/typescript
import _ from "lodash"
import * as utils from "./utils"
import jQueryHttpClient from "./jQueryHttpClient"
import * as quickfind from "./quickfind"
import {
MinimongoDb,
MinimongoCollection,
Doc,
MinimongoCollectionFindOptions,
MinimongoCollectionFindOneOptions,
HttpClient
} from "./types"
import { MinimongoBaseCollection } from "."
import sha1 from "js-sha1"
export default class RemoteDb implements MinimongoDb {
collections: { [collectionName: string]: Collection<any> }
url: string | string[]
client: string | null | undefined
httpClient: HttpClient
useQuickFind: boolean
usePostFind: boolean
/** Url must have trailing /, can be an arrau of URLs
* useQuickFind enables the quickfind protocol for finds
* usePostFind enables POST for find
*/
constructor(
url: string | string[],
client?: string | null,
httpClient?: any,
useQuickFind = false,
usePostFind = false
) {
this.url = url
this.client = client
this.collections = {}
this.httpClient = httpClient
this.useQuickFind = useQuickFind
this.usePostFind = usePostFind
}
// Can specify url of specific collection as option.
// useQuickFind can be overridden in options
// usePostFind can be overridden in options
addCollection(
name: string,
options: { url?: string; useQuickFind?: boolean; usePostFind?: boolean } = {},
success?: any,
error?: any
) {
let url: string | string[]
if (_.isFunction(options)) {
;[options, success, error] = [{}, options, success]
}
if (options.url) {
;({ url } = options)
} else {
if (_.isArray(this.url)) {
url = _.map(this.url, (url: any) => url + name)
} else {
url = this.url + name
}
}
let { useQuickFind } = this
if (options.useQuickFind != null) {
;({ useQuickFind } = options)
}
let { usePostFind } = this
if (options.usePostFind != null) {
;({ usePostFind } = options)
}
const collection = new Collection(name, url, this.client, this.httpClient, useQuickFind, usePostFind)
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()
}
}
getCollectionNames() {
return _.keys(this.collections)
}
}
// Remote collection on server
class Collection<T extends Doc> implements MinimongoBaseCollection<T> {
name: string
url: string | string[]
client?: string | null
httpClient: HttpClient
useQuickFind: any
usePostFind: any
/** Cycles through urls if array and not GET request */
urlIndex: number
// usePostFind allows POST to <collection>/find for long selectors
constructor(name: string, url: string | string[], client: string | null | undefined, httpClient: any, useQuickFind: any, usePostFind: any) {
this.name = name
this.url = url
this.client = client
this.httpClient = httpClient || jQueryHttpClient
this.useQuickFind = useQuickFind
this.usePostFind = usePostFind
this.urlIndex = 0
}
/** Get a URL to use from an array, if present.
* Stable if a GET request, but not if a POST, etc request
* to allow caching. Accomplished by passing in a key
* to use for hashing for GET only.
*/
getUrl(key?: string) {
if (typeof this.url === "string") {
return this.url
}
// If no key, use next URL
if (!key) {
const url = this.url[this.urlIndex]
this.urlIndex = (this.urlIndex + 1) % this.url.length
return url
}
// Hash key to get index
const hash = sha1.create()
hash.update(key)
// Get 4 hex digits
const partial = hash.hex().substr(0, 4)
// Convert to integer
const index = parseInt(partial, 16)
// Get URL
return this.url[index % this.url.length]
}
// error is called with jqXHR
find(selector: any, options: MinimongoCollectionFindOptions = {}) {
return {
fetch: (success?: any, error?: any) => {
return this._findFetch(selector, options, success, error)
}
}
}
_findFetch(selector: any, options: MinimongoCollectionFindOptions, success: any, error: any): any {
// If promise case
if (success == null) {
return new Promise((resolve, reject) => {
this._findFetch(selector, options, resolve, reject)
})
}
// Determine method: "get", "post" or "quickfind"
// If in quickfind and localData present and (no fields option or _rev included) and not (limit with no sort), use quickfind
let method
if (
this.useQuickFind &&
options.localData &&
(!options.fields || options.fields._rev) &&
!(options.limit && !options.sort && !options.orderByExprs)
) {
method = "quickfind"
// If selector or fields or sort is too big, use post
} else if (
this.usePostFind &&
JSON.stringify({ selector, sort: options.sort, fields: options.fields }).length > 500
) {
method = "post"
} else {
method = "get"
}
if (method === "get") {
// Create url
const params: any = {}
params.selector = JSON.stringify(selector || {})
if (options.sort) {
params.sort = JSON.stringify(options.sort)
}
if (options.limit) {
params.limit = options.limit
}
if (options.skip) {
params.skip = options.skip
}
if (options.fields) {
params.fields = JSON.stringify(options.fields)
}
// Advanced options for mwater-expression-based filtering and ordering
if (options.whereExpr) {
params.whereExpr = JSON.stringify(options.whereExpr)
}
if (options.orderByExprs) {
params.orderByExprs = JSON.stringify(options.orderByExprs)
}
if (this.client) {
params.client = this.client
}
this.httpClient("GET", this.getUrl(this.name + JSON.stringify(params)), params, null, success, error)
return
}
// Create body + params for quickfind and post
const body = {
selector: selector || {}
} as any
if (options.sort) {
body.sort = options.sort
}
if (options.limit != null) {
body.limit = options.limit
}
if (options.skip != null) {
body.skip = options.skip
}
if (options.fields) {
body.fields = options.fields
}
// Advanced options for mwater-expression-based filtering and ordering
if (options.whereExpr) {
body.whereExpr = options.whereExpr
}
if (options.orderByExprs) {
body.orderByExprs = options.orderByExprs
}
const params: any = {}
if (this.client) {
params.client = this.client
}
if (method === "quickfind") {
// Send quickfind data
body.quickfind = quickfind.encodeRequest(options.localData)
this.httpClient(
"POST",
this.getUrl() + "/quickfind",
params,
body,
(encodedResponse: any) => {
return success(quickfind.decodeResponse(encodedResponse, options.localData, options.sort))
},
error
)
return
}
// POST method
this.httpClient(
"POST",
this.getUrl() + "/find",
params,
body,
(response: any) => {
return success(response)
},
error
)
return
}
// error is called with jqXHR
// Note that findOne is not used by HybridDb, but rather find with limit is used
findOne(selector: any, options?: MinimongoCollectionFindOneOptions): Promise<T | null>
findOne(
selector: any,
options: MinimongoCollectionFindOneOptions,
success: (doc: T | null) => void,
error: (err: any) => void
): void
findOne(selector: any, success: (doc: T | null) => void, error: (err: any) => void): void
findOne(selector: any, options?: any, success?: any, error?: 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, resolve, reject)
})
}
// TODO would require documentation change and major bump
// // Use simple GET if no options and selector is by _id only
// if (_.isEmpty(options) && _.isEqual(selector, { _id: selector._id })) {
// return this.httpClient(
// "GET",
// this.getUrl(this.name + "/" + selector._id),
// { client: this.client },
// null,
// function (result: any) {
// success(result ?? null)
// },
// (jqXHR) => {
// if (jqXHR.status === 404) {
// return success(null)
// } else {
// return error(jqXHR)
// }
// }
// )
// }
// Create url
const params: any = {}
if (options.sort) {
params.sort = JSON.stringify(options.sort)
}
params.limit = 1
if (this.client) {
params.client = this.client
}
params.selector = JSON.stringify(selector || {})
return this.httpClient(
"GET",
this.getUrl(this.name + "?" + JSON.stringify(params)),
params,
null,
function (results: any) {
if (results && results.length > 0) {
return success(results[0])
} else {
return success(null)
}
},
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 promise case
if (!success && !_.isFunction(bases)) {
return new Promise((resolve, reject) => {
this.upsert(
docs,
bases,
resolve,
reject
)
})
}
let items: { doc: T; base?: T }[]
;[items, success, error] = utils.regularizeUpsert(docs, bases, success, error)
const results = []
// Check if bases present
const basesPresent = _.compact(_.map(items, "base")).length > 0
const params: any = {}
if (this.client) {
params.client = this.client
}
// Handle single case
if (items.length === 1) {
// POST if no base, PATCH otherwise
if (basesPresent) {
return this.httpClient(
"PATCH",
this.getUrl(),
params,
items[0],
function (result: any) {
if (_.isArray(docs)) {
return success([result])
} else {
return success(result)
}
},
function (err: any) {
if (error) {
return error(err)
}
}
)
} else {
return this.httpClient(
"POST",
this.getUrl(),
params,
items[0].doc,
function (result: any) {
if (_.isArray(docs)) {
return success([result])
} else {
return success(result)
}
},
function (err: any) {
if (error) {
return error(err)
}
}
)
}
} else {
// POST if no base, PATCH otherwise
if (basesPresent) {
return this.httpClient(
"PATCH",
this.getUrl(),
params,
{ doc: _.map(items, "doc"), base: _.map(items, "base") },
(result: any) => success(result),
function (err: any) {
if (error) {
return error(err)
}
}
)
} else {
return this.httpClient(
"POST",
this.getUrl(),
params,
_.map(items, "doc"),
(result: any) => success(result),
function (err: any) {
if (error) {
return error(err)
}
}
)
}
}
}
// error is called with jqXHR
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)
})
}
if (!this.client) {
throw new Error("Client required to remove")
}
const params = { client: this.client }
return this.httpClient("DELETE", this.getUrl() + "/" + id, params, null, success, function (err: any) {
// 410 is an acceptable delete status
if (err.status === 410) {
return success()
} else {
return error!(err)
}
})
}
}