diff --git a/package-lock.json b/package-lock.json index e46620d..44136ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@creately/rxdata", - "version": "5.1.4", + "version": "6.2.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -209,6 +209,11 @@ "integrity": "sha512-0VU8WsYEOYmoxVtUuafB66/+9G8gMdGAC3dhCE/CMpjXgNIz9fwC6g41kRlNxFgpQI/uX/aWNLArNg7KmeFYvw==", "dev": true }, + "@types/lokijs": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/@types/lokijs/-/lokijs-1.5.7.tgz", + "integrity": "sha512-WEFQLgO3u2Wa7yFybqkTZYumqF1GcHvUwx8Tv2SUHy2qpnIainMMoLmEUGdjhPNp/v5pyC9e91fSMC3mkxBIDw==" + }, "@webassemblyjs/ast": { "version": "1.7.11", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.11.tgz", @@ -2526,11 +2531,6 @@ "minimatch": "^3.0.4" } }, - "immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" - }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3053,14 +3053,6 @@ "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true }, - "lie": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", - "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", - "requires": { - "immediate": "~3.0.5" - } - }, "loader-runner": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.3.1.tgz", @@ -3078,22 +3070,6 @@ "json5": "^0.5.0" } }, - "localforage": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz", - "integrity": "sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ==", - "requires": { - "lie": "3.1.1" - } - }, - "localforage-setitems": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/localforage-setitems/-/localforage-setitems-1.4.0.tgz", - "integrity": "sha1-NrhZDVB9+1yAQDPih+zljYiZbV8=", - "requires": { - "localforage": ">=1.4.0" - } - }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -3126,6 +3102,11 @@ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" }, + "lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==" + }, "log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -3206,6 +3187,11 @@ "object.assign": "^4.1.0" } }, + "lokijs": { + "version": "1.5.12", + "resolved": "https://registry.npmjs.org/lokijs/-/lokijs-1.5.12.tgz", + "integrity": "sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q==" + }, "loose-envify": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", @@ -4206,9 +4192,9 @@ } }, "rxjs": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.3.3.tgz", - "integrity": "sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", + "integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==", "requires": { "tslib": "^1.9.0" } diff --git a/package.json b/package.json index 23b65a0..62c6b6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@creately/rxdata", - "version": "5.1.4", + "version": "6.2.4", "description": "", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -39,10 +39,11 @@ "dependencies": { "@creately/lschannel": "^2.0.3", "@creately/mungo": "^1.2.1", - "localforage": "^1.7.3", - "localforage-setitems": "^1.4.0", + "@types/lokijs": "^1.5.7", "lodash.clonedeep": "^4.5.0", "lodash.isequal": "^4.5.0", + "lodash.omit": "^4.5.0", + "lokijs": "^1.5.12", "mingo": "^2.2.6", "rxjs": "^6.0.0" } diff --git a/src/__module.d.ts b/src/__module.d.ts index 265677d..669f5a7 100644 --- a/src/__module.d.ts +++ b/src/__module.d.ts @@ -2,3 +2,4 @@ declare module 'mingo'; declare module 'clone'; declare module 'lodash.isequal'; declare module 'lodash.clonedeep'; +declare module 'lodash.omit'; diff --git a/src/__tests__/collection.spec.ts b/src/__tests__/collection.spec.ts index 08ed8a8..d9b9e66 100644 --- a/src/__tests__/collection.spec.ts +++ b/src/__tests__/collection.spec.ts @@ -105,11 +105,16 @@ describe('Collection', () => { it('should emit modified documents if they match the selector', async done => { const { col } = await prepare(); const watchPromise = watchN(col, 1, { z: 3 }); + const watchPromise2 = watchN(col, 1, { z: 2 }); await col.insert(TEST_DOCS); const out = await watchPromise; + const out2 = await watchPromise2; expect(out).toEqual([ { id: (jasmine.any(Number) as any) as number, type: 'insert', docs: TEST_DOCS.filter(doc => doc.z === 3) }, ]); + expect(out2).toEqual([ + { id: (jasmine.any(Number) as any) as number, type: 'insert', docs: TEST_DOCS.filter(doc => doc.z === 2) }, + ]); done(); }); @@ -336,6 +341,25 @@ describe('Collection', () => { done(); }); + it('should update the exsiting query / upsert', async done => { + const { col } = await prepare(); + const promise1 = watchN(col, 1); + await col.insert(TEST_DOCS); + let out = await promise1; + expect(out).toEqual([{ id: (jasmine.any(Number) as any) as number, type: 'insert', docs: [...TEST_DOCS] }]); + + const promise2 = watchN(col, 1); + await col.insert(Object.freeze({ id: 'd111', x: 2, y: 2, z: 2 })); + out = await promise2; + expect((col as any).storage.data.length).toEqual(TEST_DOCS.length); + expect((col as any).storage.data.filter((doc: any) => doc.id === 'd111').length).toEqual(1); + const updated = (col as any).storage.data.find((doc: any) => doc.id === 'd111'); + expect(updated.x).toEqual(2); + expect(updated.y).toEqual(2); + expect(updated.z).toEqual(2); + done(); + }); + it('should emit the inserted document as a change (remote)'); }); diff --git a/src/__tests__/database.spec.ts b/src/__tests__/database.spec.ts index 5404617..bd32e8e 100644 --- a/src/__tests__/database.spec.ts +++ b/src/__tests__/database.spec.ts @@ -94,6 +94,7 @@ describe('Database', () => { const { db } = prepare(); const c1 = db.collection('test'); await c1.insert([{ id: 'd1' }]); + expect(Collection.loki.getCollection(`rxdata.test-db.test`).data[0].id).toEqual('d1'); expect(await findN(c1, 1)).toEqual([[{ id: 'd1' }]]); await db.drop(); expect(await findN(c1, 1)).toEqual([[]]); diff --git a/src/collection.ts b/src/collection.ts index 28237f5..756f4d6 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -1,10 +1,8 @@ -/// - import mingo from 'mingo'; -import * as LocalForage from 'localforage'; -import 'localforage-setitems'; +import * as Loki from 'lokijs'; import * as isequal from 'lodash.isequal'; import * as cloneDeep from 'lodash.clonedeep'; +import * as omit from 'lodash.omit'; import { Observable, Subject, empty, of, defer, from, Subscription } from 'rxjs'; import { switchMap, concatMap, concat, map, distinctUntilChanged } from 'rxjs/operators'; import { modify } from '@creately/mungo'; @@ -72,17 +70,14 @@ export type DocumentChange = InsertDocumentChange | RemoveDocumentChange { + public static loki = new Loki('rxdatalokijs'); // allDocs // allDocs emits all documents in the collection when they get modified. protected allDocs: Subject; // storage // storage stores documents in a suitable storage backend. - protected storage: LocalForage; - - // cachedDocs - // cachedDocs is an in memory cache of all documents in the database. - protected cachedDocs: T[] | null = null; + protected storage: Loki.Collection; // channel // channel sends/receives messages between browser tabs. @@ -107,7 +102,7 @@ export class Collection { // constructor constructor(public name: string) { this.allDocs = new Subject(); - this.storage = LocalForage.createInstance({ name }); + this.storage = Collection.loki.addCollection(name); this.changes = Channel.create(`rxdata.${name}.channel`); this.changesSub = this.changes.pipe(concatMap(change => from(this.apply(change)))).subscribe(); } @@ -130,12 +125,17 @@ export class Collection { // made to documents which match the selector. public watch(selector?: Selector): Observable> { if (!selector) { - return this.changes.asObservable(); + return this.changes.asObservable().pipe( + map(change => { + const docs = change.docs.map(doc => omit(doc, '$loki', 'meta')); + return Object.assign({}, change, { docs }); + }) + ); } const mq = new mingo.Query(selector); return this.changes.pipe( switchMap(change => { - const docs = change.docs.filter(doc => mq.test(doc)); + const docs = change.docs.filter(doc => mq.test(doc)).map(doc => omit(doc, '$loki', 'meta')); if (!docs.length) { return empty(); } @@ -178,7 +178,17 @@ export class Collection { // with the id already exists in the collection, it will be replaced. public async insert(docOrDocs: T | T[]): Promise { const docs: T[] = cloneDeep(Array.isArray(docOrDocs) ? docOrDocs : [docOrDocs]); - await this.storage.setItems(docs.map(doc => ({ key: (doc as any).id, value: doc }))); + docs.forEach(doc => { + var existingDoc = this.storage.findOne({ id: doc.id }); + if (existingDoc) { + // If a matching document exists, update it + Object.assign(existingDoc, doc); + this.storage.update(existingDoc); + } else { + // If no matching document exists, insert the new document + this.storage.insert(doc); + } + }); await this.emitAndApply({ id: this.nextChangeId(), type: 'insert', docs: docs }); } @@ -189,8 +199,10 @@ export class Collection { const modifier = cloneDeep(_modifier); const filter = this.createFilter(selector); const docs: T[] = cloneDeep((await this.load()).filter(doc => filter(doc))); - docs.forEach(doc => modify(doc, modifier)); - await this.storage.setItems(docs.map(doc => ({ key: (doc as any).id, value: doc }))); + docs.forEach(doc => { + modify(doc, modifier); + this.storage.update(doc); + }); await this.emitAndApply({ id: this.nextChangeId(), type: 'update', docs: docs, modifier: modifier }); } @@ -200,10 +212,15 @@ export class Collection { public async remove(selector: Selector): Promise { const filter = this.createFilter(selector); const docs = (await this.load()).filter(doc => filter(doc)); - await Promise.all(docs.map(doc => this.storage.removeItem((doc as any).id))); + docs.map(doc => this.storage.remove(doc)); await this.emitAndApply({ id: this.nextChangeId(), type: 'remove', docs: docs }); } + public async dropCollection() { + Collection.loki.removeCollection(this.name); + await this.reload(); + } + // filter // filter returns an array of documents which match the selector and // filter options. The selector, options and all option fields are optional. @@ -218,7 +235,7 @@ export class Collection { if (options.limit) { cursor = cursor.limit(options.limit); } - return cursor.all(); + return cursor.all().map((doc: any) => omit(doc, '$loki', 'meta')); } // createFilter @@ -231,13 +248,7 @@ export class Collection { // load // load loads all documents from the database to the in-memory cache. protected load(): Promise { - if (this.cachedDocs) { - return Promise.resolve(this.cachedDocs); - } - if (!this.loadPromise) { - this.loadPromise = this.loadAll().then(docs => (this.cachedDocs = docs)); - } - return this.loadPromise.then(() => this.cachedDocs as T[]); + return Promise.resolve(this.storage.data); } // Reload @@ -245,8 +256,7 @@ export class Collection { // the cachedDocs and emit the updated docs. public async reload() { return this.loadAll().then(docs => { - this.cachedDocs = docs; - this.allDocs.next(this.cachedDocs); + this.allDocs.next(docs); }); } @@ -254,41 +264,14 @@ export class Collection { // loadAll loads all documents from storage without filtering. // Returns a promise which resolves to an array of documents. protected async loadAll(): Promise { - const docs: T[] = []; - await this.storage.iterate((doc: T) => { - docs.push(doc); - // If a non-undefined value is returned, - // the localforage iterator will stop. - return undefined; - }); - return docs; + return Promise.resolve(this.storage.data); } // refresh // refresh loads all documents from localForage storage and emits it // to all listening queries. Called when the collection gets changed. protected async apply(change: DocumentChange) { - if (!this.cachedDocs) { - this.cachedDocs = await this.load(); - } - if (change.type === 'insert' || change.type === 'update') { - for (const doc of change.docs) { - const index = this.cachedDocs.findIndex(d => d.id === doc.id); - if (index === -1) { - this.cachedDocs.push(doc); - } else { - this.cachedDocs[index] = doc; - } - } - } else if (change.type === 'remove') { - for (const doc of change.docs) { - const index = this.cachedDocs.findIndex(d => d.id === doc.id); - if (index !== -1) { - this.cachedDocs.splice(index, 1); - } - } - } - this.allDocs.next(this.cachedDocs); + this.allDocs.next(this.storage.data); const resolveFn = this.changeResolve[change.id]; if (resolveFn) { resolveFn(); @@ -306,7 +289,7 @@ export class Collection { // emitAndApply emits the change and waits until it is applied. private async emitAndApply(change: DocumentChange): Promise { await new Promise(resolve => { - this.changeResolve[change.id] = resolve; + this.changeResolve[change.id] = resolve as any; this.changes.next(change); }); } diff --git a/src/database.ts b/src/database.ts index 7b34c2c..607207d 100644 --- a/src/database.ts +++ b/src/database.ts @@ -53,10 +53,10 @@ export class Database { // drop clears all data in all collections in the database. // It also closes all active subscriptions in all collections. public async drop(): Promise { - this.collections = new Map(); - const collections = this.collectionsList; this.collectionsList = []; - await Promise.all(collections.map(name => new Collection(name).remove({}))); + this.collections.forEach(async c => await c.dropCollection()); + this.collections = new Map(); + await Promise.resolve(); } // collectionsListKey