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: export default class IndexedDb implements MinimongoLocalDb { collections: { [collectionName: string]: IndexedDbCollection } 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 implements MinimongoLocalCollection { 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 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 upsert(doc: T, base: T | null | undefined): Promise 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 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((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 } ) } }