diff --git a/packages/pouchorm/README.md b/packages/pouchorm/README.md new file mode 100644 index 0000000..5be06a5 --- /dev/null +++ b/packages/pouchorm/README.md @@ -0,0 +1,140 @@ +# PouchORM + +An ORM for PouchDB inspired by [Hood.ie](https://hood.ie/) and [Django](https://djangoproject.com) + +## Usage + +### Type Definition + +In a type definition class, specify a static `getUniqueID` method and optionally a static `validate` +method. + +```js +import { PouchDB } from 'pouchdb'; +import { TypeSpec } from 'PouchORM'; + +class FileSpec extends TypeSpec { + static getUniqueID(doc) { + return doc.digest.substr(0, 16); + } + + static validate(doc) { + const REQUIRED_PROPS = ['filename', 'size', 'digest']; + const missing_props = REQUIRED_PROPS.filter(prop => !doc.hasOwnProperty(prop)); + if (missing_props) { + throw new ValidationError('FileType missing required props: ', missing_props); + } + } +} + +export const FileType = PouchDB.registerType('File', FileSpec); +``` + +Document properties of a type are not directly defined but the type definition. Specific properties +can be enforced by the `validate` method. The following properties are illegal: + +* starting with an underscore (\_) - these are used by PouchDB internally +* starting with a double dollar-sign ($$) - these are reserved for future use by PouchORM. (Single + dollar-sign properties may be used for your implementation’s meta-data.) + +### Type Functions + +* `FileType.getOrCreate(obj)` - Try to save the passed object as a document. If it fails with a /409 + Conflict/, look up the existing one and return it (ignoring differences between passed object and + the found object) + + * /obj/ - an object containing a partial document including an `id` property + * _Returns_ a FileType instance + +* `FileType.find(idOrSelector, options)` - Find one or many records. Optionally return a + subscribeable that will fire events when the selection changes. + + * /idOrSelector/ + * if a string or number is passed, `find` will attempt to lookup the document with this as the + id. + * if an object is passed, it will be treated as a selector for a find query + * /options/ can have the following optional properties + * /index/ - specify a specific index to use with the passed selector + * /live/ - boolean (default /false/) return a subscribeable that will fire when the results of + the selector are updated. + * /…/ - other options will be passed to `db.changes()` when `live: true` + * _Returns_ + * if `options.live` is /false/, an array of FileType instances will be returned + * if `options.live` is /true/, a subscribeable will be returned. Subscription callbacks will be + called when the results of /idOrSelector/ changes + +* `FileType.index(index, fields)` - Create a PouchDB + [Index](https://pouchdb.com/api.html#create_index) that can be used by `FileType.find` to sort on + non-ID fields + + * /index/ - a string value that can optionally be provided to the options object of + `FileType.find` + * /fields/ - an array of field names for PouchDB to index on. + * _Returns_ void + +* `FileType.delete(id)` - Remove a document from the db + + * /id/ - the id string of the document to be removed + * _Returns_ void + +* `FileType.subscribe(callback)` - subscribe to all FileType changes + * _Returns_ - a function that unsubscribes the passed callback when called. + +### Type Properties + +* `FileType.db` - the PouchDB database used by `FileType` +* `FileType.name` - the type name passed to the `registerType` function +* `FileType.prefix` - the prefix used on all document ids stored in PouchDB +* `FileType.selector` - the PouchDB selector definition used by ‘subscribe’ and ‘find’ + +### Instance Methods + +Creating a new instance is standard Javascript for any new instance: + +```js +let note = new FileType({ + filename: 'note.txt', + type: 'text/plain' +}); +``` + +* _static_ `note.getUniqueID(doc)` - You _must override_ this function in your type definition to + provide the id to be used for this document. + + * /doc/ - an object representing the document. This could be an instance of the type or a bare + object with properties for this type. + * _Returns_ - a unique string value to be used as a document id + +* _static_ `note.validate()` - By default this does nothing. Optionally override it with a method + that throws exceptions when a document is invalid to block writing invalid data to the database. + +* `note.save()` - Calls `validate()` and then writes the document to the database. + + * _Returns_ - a self-reference to the instance + +* `note.update(props, save=true)` - Update this instance + + * /props/ - a deeply nested object that will be applied to this instance + * /save/ - a boolean to save this document after updating. + * _Returns_ - a self-reference to the instance + +* `note.addAttachment(name, blob)` - Add a named attachment to this instance. + + * /name/ - the name used to reference the attachment + * /blob/ - a blob instance of the actual data + * _Returns_ - a self-reference to the instance + +* `note.getAttachment(name)` - Retrieve a named attachment from this instance. + + * /name/ - the name of the attachment specified in `addAttachment` + * _Returns_ - a Blob instance of the attachment data + +* `note.removeAttachment(name)` - Remove a named attachment from this instance. + + * /name/ - the name of the attachment specified in `addAttachment` + * _Returns_ - + +* `note.delete()` - Flag this document as `_deleted` == `true`. This will cause it to not show up in + `find` results. (_Note_: documents are left in the database in this state to allow for deletion to + be synced to other nodes. To truly remove documents, + [compact your database](https://pouchdb.com/api.html#compaction).) diff --git a/packages/pouchorm/package.json b/packages/pouchorm/package.json new file mode 100644 index 0000000..45eba91 --- /dev/null +++ b/packages/pouchorm/package.json @@ -0,0 +1,17 @@ +{ + "name": "pouchorm", + "version": "1.0.0", + "description": "Document Abstraction Layer for PouchDB", + "main": "lib/index.js", + "jsnext:main": "src/index.js", + "files": ["dist", "lib", "src"], + "scripts": { + "test": "node ../../bin/runTests.js ./" + }, + "author": "Timothy Farrell (https://github.com/explorigin)", + "license": "Apache-2.0", + "dependencies": { + "frptools": "^3.1.0" + }, + "devDependencies": {} +} diff --git a/packages/pouchorm/rollup.config.js b/packages/pouchorm/rollup.config.js new file mode 100644 index 0000000..a832a0d --- /dev/null +++ b/packages/pouchorm/rollup.config.js @@ -0,0 +1,21 @@ +import json from 'rollup-plugin-json'; +import babel from 'rollup-plugin-babel'; + +const babelConfig = { + env: { + es6: true, + browser: true + }, + plugins: [], + presets: ['es2015-rollup'] +}; + +export default { + entry: 'src/index.js', + moduleName: 'pouchorm', + plugins: [json(), babel(babelConfig)], + output: { + format: 'umd', + file: 'dist/pouchorm.js' + } +}; diff --git a/packages/pouchorm/spec/livearray.spec.js b/packages/pouchorm/spec/livearray.spec.js new file mode 100644 index 0000000..f5d0eb8 --- /dev/null +++ b/packages/pouchorm/spec/livearray.spec.js @@ -0,0 +1,117 @@ +import { LiveArray } from '../src/livearray.js'; +import { pouchDocArrayHash } from '../src/utils.js'; + +describe('A LiveArray', () => { + let fakePouch; + const selector = {}; + const opts = {}; + + beforeEach(() => { + fakePouch = { + find: () => {}, + changes: () => fakePouch, + on: () => fakePouch + }; + }); + + it('returns a computed (subscribable).', () => { + const la = LiveArray(fakePouch, selector, opts); + + expect(typeof la).toEqual('function'); + expect(typeof la.subscribe).toEqual('function'); + + expect(JSON.stringify(la())).toEqual('[]'); + }); + + it('has a "ready" subscribable that is fired once and subscribers cleared when the main subscribable has data for the first time.', () => { + let sub = null; + + const db = { + changes: options => { + return db; + }, + on: (eventName, callback) => { + if (eventName == 'change') { + sub = callback; + } + expect(['change', 'error'].indexOf(eventName) !== -1).toBeTruthy(); + return db; + }, + cancel: () => (sub = null), + find: async selector => { + return Promise.resolve({ docs: [{ _id: 234 }] }); + } + }; + + const la = LiveArray(db, selector, opts); + + return new Promise((resolve, reject) => { + const unsub = la.ready.subscribe(() => { + unsub(); + resolve(); + }); + }); + }); + + it('fires when data changes.', () => { + let state = 0; + const changes = { + 234: { id: 234, deleted: false, doc: { _id: 234 } }, + 34: { id: 34, deleted: true, doc: { _id: 34 } }, + 4564565: { id: 4564565, deleted: false, doc: { _id: 4564565 } } + }; + const changeKeys = Object.keys(changes); + let sub = null; + + const db = { + changes: options => { + expect(state).toEqual(1); + expect(options.live).toEqual(true); + expect(options.since).toEqual('now'); + expect(options.selector).toBe(selector); + return db; + }, + on: (eventName, callback) => { + if (eventName == 'change') { + sub = callback; + } + expect(['change', 'error'].indexOf(eventName) !== -1).toBeTruthy(); + return db; + }, + cancel: () => (sub = null), + find: async selector => { + const doc = changes[parseInt(changeKeys[state - 1])]; + state += 1; + if (doc === undefined || doc.deleted) { + return Promise.resolve({ docs: [] }); + } + return Promise.resolve({ docs: [doc.doc] }); + } + }; + + const la = LiveArray(db, selector, opts); + state = 1; + + let innerState = 0; + + return new Promise((resolve, reject) => { + const unsub = la.ready.subscribe(() => { + la.subscribe(data => { + if (data.length) { + expect(data[0]._id).toEqual(parseInt(changeKeys[innerState])); + } + innerState += 1; + + resolve(); + }); + + changeKeys.forEach(id => { + const doc = changes[parseInt(id)]; + sub(doc); + }); + + unsub(); + }); + }); + }); +}); diff --git a/packages/pouchorm/spec/pouchorm.spec.js b/packages/pouchorm/spec/pouchorm.spec.js new file mode 100644 index 0000000..6c93e90 --- /dev/null +++ b/packages/pouchorm/spec/pouchorm.spec.js @@ -0,0 +1,7 @@ +import { PouchORM } from '../src/index.js'; + +// describe('A PouchORM Document', () => { +// it('.', () => { +// expect().nothing(); +// }); +// }); diff --git a/packages/pouchorm/spec/support/jasmine.json b/packages/pouchorm/spec/support/jasmine.json new file mode 100644 index 0000000..b52c9e6 --- /dev/null +++ b/packages/pouchorm/spec/support/jasmine.json @@ -0,0 +1,7 @@ +{ + "spec_dir": "spec", + "spec_files": ["**/*[sS]pec.js"], + "helpers": ["helpers/**/*.js"], + "stopSpecOnExpectationFailure": false, + "random": false +} diff --git a/packages/pouchorm/spec/watcher.spec.js b/packages/pouchorm/spec/watcher.spec.js new file mode 100644 index 0000000..fc38e03 --- /dev/null +++ b/packages/pouchorm/spec/watcher.spec.js @@ -0,0 +1,148 @@ +import { Watcher } from '../src/watcher.js'; + +describe('A watcher', () => { + const selector = {}; + const opts = {}; + + it('initially does nothing.', () => { + const db = { + changes: () => fail('Watcher should not call changes until the first subscription.') + }; + + const w = Watcher(db, selector, opts); + expect().nothing(); + }); + + it('calls PouchDB.change API on the first subscription.', () => { + let state = 0; + const db = { + changes: options => { + expect(state).toEqual(1); + expect(options.live).toEqual(true); + expect(options.since).toEqual('now'); + expect(options.selector).toBe(selector); + return db; + }, + on: (eventName, callback) => { + expect(['change', 'error'].indexOf(eventName) !== -1).toBeTruthy(); + return db; + }, + cancel: () => {} + }; + + const w = Watcher(db, selector, opts); + state = 1; + w(() => fail('Subscription callback should not be called until data changes.')); + }); + + it('cancels change subscription when the last subscriber unsubscribes', () => { + let state = 0; + const db = { + changes: options => { + expect(state).toEqual(1); + expect(options.live).toEqual(true); + expect(options.since).toEqual('now'); + expect(options.selector).toBe(selector); + return db; + }, + on: (eventName, callback) => { + expect(['change', 'error'].indexOf(eventName) !== -1).toBeTruthy(); + return db; + }, + cancel: () => { + expect(state).toEqual(2); + state = 3; + } + }; + + const w = Watcher(db, selector, opts); + state = 1; + const unsub = w(() => fail('Subscription callback should not be called until data changes.')); + state = 2; + unsub(); + expect(state).toEqual(3); + }); + + it('passes change events to subscribers.', () => { + let state = 0; + const changes = { + 234: { id: 234, deleted: false, doc: { _id: 234 } }, + 34: { id: 34, deleted: true, doc: { _id: 34 } }, + 4564565: { id: 4564565, deleted: false, doc: { _id: 4564565 } } + }; + let sub = null; + const db = { + changes: options => { + expect(state).toEqual(1); + expect(options.live).toEqual(true); + expect(options.since).toEqual('now'); + expect(options.selector).toBe(selector); + return db; + }, + on: (eventName, callback) => { + if (eventName == 'change') { + sub = callback; + } + expect(['change', 'error'].indexOf(eventName) !== -1).toBeTruthy(); + return db; + }, + cancel: () => (sub = null) + }; + + const w = Watcher(db, selector, opts); + state = 1; + w((id, deleted, doc) => { + expect(changes.hasOwnProperty(id)).toBeTruthy(); + expect(changes[id].doc).toBe(doc); + expect(changes[id].deleted).toEqual(deleted); + }); + + Object.values(changes).forEach(sub); + }); + + it('dumps subscribers when an error event happens.', () => { + let state = 0; + const changes = { + 234: { id: 234, deleted: false, doc: { _id: 234 } }, + 34: { id: 34, deleted: true, doc: { _id: 34 } }, + 4564565: { id: 4564565, deleted: false, doc: { _id: 4564565 } } + }; + let sub = null; + let errorSub = null; + const db = { + changes: options => { + expect(state).toEqual(1); + expect(options.live).toEqual(true); + expect(options.since).toEqual('now'); + expect(options.selector).toBe(selector); + return db; + }, + on: (eventName, callback) => { + if (eventName == 'change') { + sub = callback; + } + if (eventName == 'error') { + errorSub = callback; + } + expect(['change', 'error'].indexOf(eventName) !== -1).toBeTruthy(); + return db; + }, + cancel: () => { + sub = null; + errorSub = null; + } + }; + + const w = Watcher(db, selector, opts); + state = 1; + w(() => fail('Subscription callback should not be called until data changes.')); + + const error = new Error('TestError'); + try { + errorSub(error); + } catch (e) { + expect(e).toBe(error); + } + Object.values(changes).forEach(sub); + }); +}); diff --git a/packages/pouchorm/src/index.js b/packages/pouchorm/src/index.js new file mode 100644 index 0000000..394e416 --- /dev/null +++ b/packages/pouchorm/src/index.js @@ -0,0 +1,3 @@ +import { isObject, deepAssign, pouchDocHash, pouchDocArrayHash } from './utils.js'; +export { TypeSpec } from './type.js'; +export { PouchORM } from './plugin.js'; diff --git a/packages/pouchorm/src/livearray.js b/packages/pouchorm/src/livearray.js new file mode 100644 index 0000000..ca663b8 --- /dev/null +++ b/packages/pouchorm/src/livearray.js @@ -0,0 +1,47 @@ +import { prop, computed, id } from '../node_modules/frptools/src/index.js'; + +import { Watcher } from './watcher.js'; +import { pouchDocArrayHash } from './utils.js'; + +// LiveArray is a subscribable property function that always returns the db results that match +// the provided selector and calls subscribers when the results change. +export function LiveArray(db, selector, opts = {}) { + const mapper = opts.mapper || id; + opts.mapper && delete opts.mapper; + opts.include_docs = true; + const _watcher = Watcher(db, selector, opts); + let changeSub = null; + + const ready = prop(false); + const data = prop({ docs: [] }); + const docs = computed(r => r.docs.map(mapper), [data], pouchDocArrayHash); + + const cleanup = () => { + docs.unsubscribeAll(); + ready.unsubscribeAll(); + if (changeSub) { + changeSub(); + changeSub = null; + } + data({ docs: [] }); + }; + + const refresh = async function refresh(...args) { + data(await db.find({ selector })); + }; + + docs.ready = ready; + docs.cleanup = cleanup; + docs.selector = selector; + docs.db = db; + + refresh() + .then(() => { + changeSub = _watcher(refresh); + ready(true); + }) + .then(() => { + ready.unsubscribeAll(); + }); + return docs; +} diff --git a/packages/pouchorm/src/plugin.js b/packages/pouchorm/src/plugin.js new file mode 100644 index 0000000..af8ef41 --- /dev/null +++ b/packages/pouchorm/src/plugin.js @@ -0,0 +1,99 @@ +import { LiveArray } from './livearray.js'; +import { isObject } from './utils.js'; + +export function PouchORM(PouchDB) { + PouchDB.registerType = (name, cls, db) => { + const prefix = name.toLowerCase(); + const _db = db || PouchDB(prefix); + _db.setMaxListeners(1000); + const _baseSelector = Object.freeze({ + _id: { $gt: `${prefix}_0`, $lt: `${prefix}_\ufff0` } + }); + const watch = Watcher(_db, _baseSelector, { include_docs: true }); + + if (!cls.hasOwnProperty('validate')) { + warn(`${cls.name} has no validation.`); + } + + const instantiate = doc => new cls(doc); + + async function find(idOrSelector, opts = {}) { + if (typeof idOrSelector === 'string') { + return instantiate(await _db.get(idOrSelector)); + } + + const isSelector = isObject(idOrSelector); + + const selector = Object.assign( + isSelector && idOrSelector._deleted ? { _deleted: true } : { _deleted: { exists: false } }, + isSelector ? idOrSelector : _baseSelector + ); + if (opts.index) { + opts.use_index = [prefix, opts.index]; + delete opts.index; + } + if (opts.live) { + opts.mapper = instantiate; + return LiveArray(_db, idOrSelector, opts); + } + return (await _db.find(Object.assign({ selector: idOrSelector }, opts))).docs.map(instantiate); + } + + async function getOrCreate(props) { + let doc = await new cls(props); + try { + await doc.save(); + } catch (e) { + if (e.status !== 409) { + throw e; + } + doc = await find(doc._id); + } + return doc; + } + + async function _delete(id) { + try { + const doc = await find(id); + doc._deleted = true; + await _db.put(doc); + } catch (e) { + if (e.status !== 404) { + throw e; + } + } + } + + async function _index(name, fields) { + return _db.createIndex({ + index: { + ddoc: prefix, + fields, + name + } + }); + } + + Object.defineProperties(cls.prototype, { + _name: { value: name }, + _prefix: { value: prefix }, + _db: { value: _db }, + _cls: { value: cls }, + _baseSelector: { value: _baseSelector } + }); + + Object.defineProperties(cls, { + getOrCreate: { value: getOrCreate }, + find: { value: find }, + index: { value: _index }, + delete: { value: _delete }, + subscribe: { value: watch }, + db: { value: _db }, + name: { value: name }, + prefix: { value: prefix }, + selector: { value: _baseSelector } + }); + + return cls; + }; +} diff --git a/packages/pouchorm/src/type.js b/packages/pouchorm/src/type.js new file mode 100644 index 0000000..8c210da --- /dev/null +++ b/packages/pouchorm/src/type.js @@ -0,0 +1,69 @@ +import { pouchDocHash } from './utils.js'; + +export class TypeSpec { + constructor(props) { + this._populateId(props); + Object.assign(this, { $links: {} }, props, { type: this._prefix }); + } + + static getUniqueID(doc) { + throw 'NotImplemented'; + } + + static validate(doc) {} + + instantiate(doc) { + return new this._cls(docs); + } + + _populateId(doc) { + if (!doc._id) { + doc._id = `${this._prefix}_${this._cls.getUniqueID(doc)}`; + } + return doc; + } + + _hash() { + return pouchDocHash(this); + } + + async delete() { + return await this.update({ _deleted: true }); + } + + async save() { + this._cls.validate(this); + const { rev } = await this._db.put(this); + this._rev = rev; + return this; + } + + async addAttachment(attName, dataBlob) { + const { rev } = await this._db.putAttachment( + this._id, + attName, + this._rev, + dataBlob, + dataBlob.type + ); + + this._rev = rev; + return this; + } + + async getAttachment(attName) { + return await this._db.getAttachment(this._id, attName); + } + + async removeAttachment(attName) { + return await this._db.removeAttachment(this._id, attName, this._rev); + } + + async update(props, save = true) { + deepAssign(this, props); + if (save) { + await this.save(); + } + return this; + } +} diff --git a/packages/pouchorm/src/utils.js b/packages/pouchorm/src/utils.js new file mode 100644 index 0000000..a398627 --- /dev/null +++ b/packages/pouchorm/src/utils.js @@ -0,0 +1,23 @@ +export function deepAssign(to, ...rest) { + for (let src of rest) { + for (let prop in src) { + const value = src[prop]; + if (typeof value === 'object' && !Array.isArray(value)) { + to[prop] = deepAssign(to[prop] || {}, value); + } else if (value === undefined && to[prop] !== undefined) { + delete to[prop]; + } else { + to[prop] = value; + } + } + } + return to; +} + +export const pouchDocHash = d => (isObject(d) ? `${d._id}:${d._rev}` : d); +export const pouchDocArrayHash = arr => + Array.isArray(arr) ? arr.map(pouchDocHash).join('?') : arr; + +export function isObject(obj) { + return typeof obj === 'object' && !Array.isArray(obj) && obj !== null; +} diff --git a/packages/pouchorm/src/watcher.js b/packages/pouchorm/src/watcher.js new file mode 100644 index 0000000..52463c4 --- /dev/null +++ b/packages/pouchorm/src/watcher.js @@ -0,0 +1,37 @@ +export function Watcher(db, selector, opts) { + const subscribers = new Set(); + let changes = null; + + return function subscribe(fn) { + subscribers.add(fn); + + if (subscribers.size === 1 && !changes) { + changes = db + .changes( + Object.assign( + { + since: 'now', + live: true, + selector + }, + opts + ) + ) + .on('change', change => { + const { id, deleted, doc } = change; + subscribers.forEach(s => s(id, !!deleted, doc)); + }) + .on('error', err => { + subscribers.clear(); + throw err; + }); + } + return () => { + subscribers.delete(fn); + if (subscribers.size === 0 && changes) { + changes.cancel(); + changes = null; + } + }; + }; +}