minimongo
Version:
Client-side mongo database with server sync over http
650 lines (588 loc) • 18.2 kB
text/typescript
import _ from "lodash"
import async from "async"
import IDBStore from "idb-wrapper"
import * as utils from "./utils"
import { processFind } from "./utils"
import { compileSort } from "./selector"
import {
MinimongoCollection,
MinimongoCollectionFindOneOptions,
MinimongoCollectionFindOptions,
MinimongoDb,
MinimongoLocalDb
} from "./types"
import { MinimongoLocalCollection } from "."
// Create a database backed by IndexedDb. options must contain namespace: <string to uniquely identify database>
export default class IndexedDb implements MinimongoLocalDb {
collections: { [collectionName: string]: IndexedDbCollection<any> }
store: any
constructor(options: any, success: any, error: any) {
this.collections = {}
// Create database
try {
this.store = new IDBStore({
dbVersion: 1,
storeName: "minimongo_" + options.namespace,
keyPath: ["col", "doc._id"],
autoIncrement: false,
onStoreReady: () => {
if (success) {
return success(this)
}
},
onError: error,
indexes: [
{ name: "col", keyPath: "col", unique: false, multiEntry: false },
{ name: "col-state", keyPath: ["col", "state"], unique: false, multiEntry: false }
]
})
} catch (ex) {
if (error) {
error(ex)
}
return
}
}
addCollection(name: string, success: any, error: any) {
const collection = new IndexedDbCollection(name, this.store)
this[name] = collection
this.collections[name] = collection
if (success) {
return success()
}
}
removeCollection(name: any, success: any, error: any) {
delete this[name]
delete this.collections[name]
// Remove all documents
return this.store.query(
(matches: any) => {
const keys = _.map(matches, (m: any) => [m.col, m.doc._id])
if (keys.length > 0) {
return this.store.removeBatch(
keys,
function () {
if (success != null) {
return success()
}
},
error
)
} else {
if (success != null) {
return success()
}
}
},
{ index: "col", keyRange: this.store.makeKeyRange({ only: name }), onError: error }
)
}
getCollectionNames() {
return _.keys(this.collections)
}
}
// Stores data in indexeddb store
class IndexedDbCollection<T> implements MinimongoLocalCollection<T> {
name: string
store: any
constructor(name: string, store: any) {
this.name = name
this.store = store
}
find(selector: any, options?: MinimongoCollectionFindOptions) {
return {
fetch: (success?: any, error?: any) => {
return this._findFetch(selector, options, success, error)
}
}
}
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)
})
}
this.find(selector, options).fetch(function (results: any) {
if (success != null) {
success(results.length > 0 ? results[0] : null)
}
}, error)
return
}
_findFetch(selector: any, options: any, success: any, error: any) {
// If promise case
if (success == null) {
return new Promise((resolve, reject) => {
this._findFetch(selector, options, resolve, reject)
})
}
// Get all docs from collection
return this.store.query(
function (matches: any) {
// Filter removed docs
matches = _.filter(matches, (m: any) => m.state !== "removed")
if (success != null) {
return success(processFind(_.map(matches, "doc"), selector, options))
}
},
{ index: "col", keyRange: this.store.makeKeyRange({ only: this.name }), onError: 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)
// Get bases
const keys = _.map(items, (item: any) => [this.name, item.doc._id])
return this.store.getBatch(
keys,
(records: any) => {
const puts = _.map(items, (item: any, i: any) => {
// Prefer explicit base
let base
if (item.base !== undefined) {
;({ base } = item)
} else if (records[i] && records[i].doc && records[i].state === "cached") {
base = records[i].doc
} else if (records[i] && records[i].doc && records[i].state === "upserted") {
;({ base } = records[i])
} else {
base = null
}
return {
col: this.name,
state: "upserted",
doc: item.doc,
base
}
})
return this.store.putBatch(
puts,
function () {
if (success) {
return success(docs)
}
},
error
)
},
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)
})
}
// Special case for filter-type remove
if (_.isObject(id)) {
this.find(id).fetch((rows: any) => {
return async.each(
rows,
((row: any, cb: any) => {
this.remove(row._id, () => cb(), cb)
}) as any,
() => success()
)
}, error)
return
}
// Find record
return this.store.get([this.name, id], (record: any) => {
// If not found, create placeholder record
if (record == null) {
record = {
col: this.name,
doc: { _id: id }
}
}
// Set removed
record.state = "removed"
// Update
return this.store.put(
record,
function () {
if (success) {
return success()
}
},
error
)
})
}
cache(docs: any, selector: any, options: any, success: any, error: any) {
const step2 = () => {
// Rows have been cached, now look for stale ones to remove
let sort: any
const docsMap = _.fromPairs(_.zip(_.map(docs, "_id"), docs))
if (options.sort) {
sort = compileSort(options.sort)
}
// Perform query, removing rows missing in docs from local db
return this.find(selector, options).fetch((results: any) => {
const removes: any = []
const keys = _.map(results, (result: any) => [this.name, result._id])
if (keys.length === 0) {
if (success != null) {
success()
}
return
}
return this.store.getBatch(
keys,
(records: any) => {
for (let i = 0, end = records.length, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
const record = records[i]
const result = results[i]
// If not present in docs and is present locally and not upserted/deleted
if (!docsMap[result._id] && record && record.state === "cached") {
// If at limit
if (options.limit && docs.length === options.limit) {
// If past end on sorted limited, ignore
if (options.sort && sort(result, _.last(docs)) >= 0) {
continue
}
// If no sort, ignore
if (!options.sort) {
continue
}
}
// Exclude any excluded _ids from being cached/uncached
if (options && options.exclude && options.exclude.includes(result._id)) {
continue
}
// Item is gone from server, remove locally
removes.push([this.name, result._id])
}
}
// If removes, handle them
if (removes.length > 0) {
return this.store.removeBatch(
removes,
function () {
if (success != null) {
return success()
}
},
error
)
} else {
if (success != null) {
return success()
}
}
},
error
)
}, error)
}
if (docs.length === 0) {
return step2()
}
// Create keys to get items
const keys = _.map(docs, (doc: any) => [this.name, doc._id])
// Create batch of puts
const puts: any = []
return this.store.getBatch(
keys,
(records: any) => {
// Add all non-local that are not upserted or removed
for (let i = 0, end = records.length, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
const record = records[i]
const doc = docs[i]
// Check if not present or not upserted/deleted
if (record == null || record.state === "cached") {
if (options && options.exclude && options.exclude.includes(doc._id)) {
continue
}
// If _rev present, make sure that not overwritten by lower or equal _rev
if (!record || !doc._rev || !record.doc._rev || doc._rev > record.doc._rev) {
puts.push({ col: this.name, state: "cached", doc })
}
}
}
// Put batch
if (puts.length > 0) {
return this.store.putBatch(puts, step2, error)
} else {
return step2()
}
},
error
)
}
pendingUpserts(success: any, error: any) {
return this.store.query(
function (matches: any) {
const upserts = _.map(matches, (m: any) => ({
doc: m.doc,
base: m.base || null
}))
if (success != null) {
return success(upserts)
}
},
{ index: "col-state", keyRange: this.store.makeKeyRange({ only: [this.name, "upserted"] }), onError: error }
)
}
pendingRemoves(success: any, error: any) {
return this.store.query(
function (matches: any) {
if (success != null) {
return success(_.map(_.map(matches, "doc"), "_id"))
}
},
{ index: "col-state", keyRange: this.store.makeKeyRange({ only: [this.name, "removed"] }), onError: error }
)
}
resolveUpserts(upserts: any, success: any, error: any) {
// Get items
const keys = _.map(upserts, (upsert: any) => [this.name, upsert.doc._id])
return this.store.getBatch(
keys,
(records: any) => {
const puts = []
for (let i = 0, end = upserts.length, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
const record = records[i]
// Only safely remove upsert if doc is the same
if (record && record.state === "upserted") {
if (JSON.stringify(record.doc) == JSON.stringify(upserts[i].doc)) {
record.state = "cached"
puts.push(record)
} else {
record.base = upserts[i].doc
puts.push(record)
}
}
}
// Put all changed items
if (puts.length > 0) {
return this.store.putBatch(
puts,
function () {
if (success) {
return success()
}
},
error
)
} else {
if (success) {
return success()
}
}
},
error
)
}
resolveRemove(id: any, success: any, error: any) {
return this.store.get([this.name, id], (record: any) => {
// Check if exists
if (!record) {
if (success != null) {
success()
}
return
}
// Only remove if removed
if (record.state === "removed") {
return this.store.remove(
[this.name, id],
function () {
if (success != null) {
return success()
}
},
error
)
}
})
}
// Add but do not overwrite or record as upsert
seed(docs: any, success: any, error: any) {
if (!_.isArray(docs)) {
docs = [docs]
}
// Create keys to get items
const keys = _.map(docs, (doc: any) => [this.name, doc._id])
// Create batch of puts
const puts: any = []
return this.store.getBatch(
keys,
(records: any) => {
// Add all non-local that are not upserted or removed
for (let i = 0, end = records.length, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
const record = records[i]
const doc = docs[i]
// Check if not present
if (record == null) {
puts.push({ col: this.name, state: "cached", doc })
}
}
// Put batch
if (puts.length > 0) {
return this.store.putBatch(
puts,
() => {
if (success != null) {
return success()
}
},
error
)
} else {
if (success != null) {
return success()
}
}
},
error
)
}
// Add but do not overwrite upsert/removed and do not record as upsert
cacheOne(doc: any, success: any, error: any) {
return this.cacheList([doc], success, error)
}
cacheList(docs: any, success: any, error: any) {
// Create keys to get items
const keys = _.map(docs, (doc: any) => [this.name, doc._id])
// Create batch of puts
const puts: any = []
return this.store.getBatch(
keys,
(records: any) => {
for (let i = 0, end = records.length, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
let record = records[i]
const doc = docs[i]
// If _rev present, make sure that not overwritten by lower equal _rev
if (record && doc._rev && record.doc._rev && doc._rev <= record.doc._rev) {
continue
}
if (record == null) {
record = {
col: this.name,
state: "cached",
doc
}
}
if (record.state === "cached") {
record.doc = doc
puts.push(record)
}
}
// Put batch
if (puts.length > 0) {
return this.store.putBatch(
puts,
() => {
if (success != null) {
return success()
}
},
error
)
} else {
if (success != null) {
return success()
}
}
},
error
)
}
uncache(selector: any, success: any, error: any) {
const compiledSelector = utils.compileDocumentSelector(selector)
// Get all docs from collection
return this.store.query(
(matches: any) => {
// Filter ones to remove
matches = _.filter(matches, (m: any) => m.state === "cached" && compiledSelector(m.doc))
const keys = _.map(matches, (m: any) => [this.name, m.doc._id])
if (keys.length > 0) {
return this.store.removeBatch(
keys,
() => {
if (success != null) {
return success()
}
},
error
)
} else {
if (success != null) {
return success()
}
}
},
{ index: "col", keyRange: this.store.makeKeyRange({ only: this.name }), onError: error }
)
}
uncacheList(ids: any, success: any, error: any) {
const idIndex = _.keyBy(ids)
// Android 2.x requires error callback
error = error || function () {}
// Get all docs from collection
return this.store.query(
(matches: any) => {
// Filter ones to remove
matches = _.filter(matches, (m: any) => m.state === "cached" && idIndex[m.doc._id])
const keys = _.map(matches, (m: any) => [this.name, m.doc._id])
if (keys.length > 0) {
return this.store.removeBatch(
keys,
() => {
if (success != null) {
return success()
}
},
error
)
} else {
if (success != null) {
return success()
}
}
},
{ index: "col", keyRange: this.store.makeKeyRange({ only: this.name }), onError: error }
)
}
}