@trust/model
Version:
A PouchDB adapter for the json-document modelling framework.
725 lines (635 loc) • 18.7 kB
JavaScript
'use strict'
/**
* Dependencies
* @ignore
*/
const PouchDB = require('pouchdb')
.plugin(require('pouchdb-find'))
/**
* Module Dependencies
* @ignore
*/
const { OperationError, InvalidConfigurationError, ValidationError, InternalError } = require('./errors')
const ModelSchema = require('./ModelSchema')
/**
* Model
*
* @class
* A PouchDB adapter for the json-document modelling framework. Extends {@link https://www.npmjs.com/package/@trust/json-document|JSONDocument}.
*
* @param {Class} superclass - should be JSONDocument or JWD
*/
const MixinModel = superclass => class Model extends superclass {
/**
* constructor
* @ignore
*/
constructor (data, options) {
// TODO FIXME
// JSON Document hack
let overrideOptions = Object.assign({}, options, { filter: false })
super(data, overrideOptions)
}
/**
* get indexes
*
* @static
* @abstract
*
* @default []
*
* @description
* An array of objects describing the indices to ensure exist when a new database
* connection is made.
*
* @see {@link https://pouchdb.com/api.html#create_index|Index}
*
* @type {Array<Index>}
*/
static get indexes () {
return []
}
/**
* get queries
*
* @static
* @abstract
*
* @default []
*
* @description
* An array of objects describing the named queries to ensure exist when a new
* database connection is made.
*
* @see {@link https://pouchdb.com/api.html#query_database|MapReduceQuery}
*
* @type {Array<MapReduceQuery>}
*/
static get queries () {
return []
}
/**
* get database
*
* @type {PouchDB}
*/
static get database () {
let { internalDatabase } = this
if (!internalDatabase) {
throw new OperationError(`Model ${this.name} has no database set`)
}
return internalDatabase
}
/**
* get sync
*
* @type {Array<Sync>}
*/
static get sync () {
if (!this.internalSync) {
this.internalSync = []
}
return this.internalSync
}
/**
* get changes
*
* @type {Array<ChangeFeed>}
*/
static get changes () {
if (!this.internalChanges) {
this.internalChanges = []
}
return this.internalChanges
}
/**
* setDatabase
*
* @static
*
* @description
* Configure the database
*
* @param {Object} options - {@link https://pouchdb.com/api.html#create_database|PouchDB Constructor Options}
*
* @return {Promise}
*/
static setDatabase (options) {
// Close current open database
let closePromise
if (this.internalDatabase) {
closePromise = this.internalDatabase.close()
} else {
closePromise = Promise.resolve()
}
// Try create database connection
try {
this.internalDatabase = new PouchDB(options)
} catch (error) {
throw new InvalidConfigurationError(`Model ${this.name} database options invalid`)
}
// Create indices
let indexPromises = this.indexes.map(index => this.createIndex(index))
// Create queries
let queryPromises = this.queries.map(query => this.createQuery(query.name, query.query))
return closePromise
.then(() => Promise.all([
Promise.all(indexPromises).catch(error => Promise.reject(new InvalidConfigurationError(`Model ${this.name} index configuration invalid`))),
Promise.all(queryPromises).catch(error => Promise.reject(new InvalidConfigurationError(`Model ${this.name} map-reduce query configuration invalid`)))
]))
.then(([indexResults, queryResults]) => indexResults.concat(queryResults))
}
/**
* setSync
*
* @static
*
* @description
* Configure a remote database for bidirectional replication
*
* @param {Object} options - {@link https://pouchdb.com/api.html#create_database|PouchDB Constructor Options}
* @param {Object} [replicationOptions = { live: true, retry: true }] - {@link https://pouchdb.com/api.html#sync|Sync Options}
*
* @return {Sync}
*/
static setSync (options, replicationOptions = { live: true, retry: true }) {
// Try create remote connection and sync
try {
let remote = new PouchDB(options)
let sync = this.database.sync(remote, replicationOptions)
sync.on('error', error => { throw new InternalError(error.message, error.stack) })
this.sync.push(sync)
return sync
} catch (error) {
throw new InvalidConfigurationError(`Model ${this.name} remote database options invalid for sync`)
}
}
/**
* replicateTo
*
* @static
*
* @description
* Configure a remote database for unidirectional replication
*
* @param {Object} options - {@link https://pouchdb.com/api.html#create_database|PouchDB Constructor Options}
* @param {Ibject} [replicationOptions = { live: true, retry: true }] - {@link https://pouchdb.com/api.html#replication|Replication Options}
*
* @return {Replication}
*/
static replicateTo (options, replicationOptions = { live: true, retry: true }) {
// Try create remote connection and replicate
try {
let remote = new PouchDB(options)
let replicate = this.database.replicate.to(remote, replicationOptions)
replicate.on('error', error => { throw new InternalError(error.message, error.stack) })
this.sync.push(replicate)
return replicate
} catch (error) {
throw new InvalidConfigurationError(`Model ${this.name} remote database options invalid for replicate`)
}
}
/**
* replicateFrom
*
* @static
*
* @description
* Configure a remote database for unidirectional replication
*
* @param {Object} options - {@link https://pouchdb.com/api.html#create_database|PouchDB Constructor Options}
* @param {Object} [replicationOptions = { live: true, retry: true }] - {@link https://pouchdb.com/api.html#replication|Replication Options}
*
* @return {Replication}
*/
static replicateFrom (options, replicationOptions = { live: true, retry: true }) {
// Try create remote connection and replicate
try {
let remote = new PouchDB(options)
let replicate = this.database.replicate.from(remote, replicationOptions)
replicate.on('error', error => { throw new InternalError(error.message, error.stack) })
this.sync.push(replicate)
return replicate
} catch (error) {
throw new InvalidConfigurationError(`Model ${this.name} remote database options invalid for replicate`)
}
}
/**
* setChanges
*
* @static
*
* @description
* Configure a change feed for the database
*
* @param {Object} [options = { live: true, include_docs: true, since: 'now' }] - {@link https://pouchdb.com/api.html#changes|PouchDB Changes Options}
*
* @return {Changes}
*/
static setChanges (options = { live: true, include_docs: true, since: 'now' }) {
let { database, changes } = this
let changeFeed = database.changes(options)
changes.push(changeFeed)
return changeFeed
}
/**
* query
*
* @static
*
* @description
* Execute a named query on the database collection.
*
* @see {@link https://pouchdb.com/api.html#query_database|MapReduceQuery}
*
* @param {String} fn - Named query (see Model.createQuery and Model.queries)
* @param {Object} [options={}] {@link https://pouchdb.com/api.html#query_database|QueryOptions}
* @return {Promise<Array<ExtendedModel>>}
*/
static query (fn, options = {}) {
let ExtendedModel = this
let { database } = this
return database.query(fn, options)
.then(results => results.docs.map(doc => new ExtendedModel(doc)))
.catch(error => Promise.reject(new InternalError(error.message, error.stack)))
}
/**
* find
*
* @static
*
* @description
* Execute an indexed query on the database collection and instantiate the extending class.
*
* @see {@link https://pouchdb.com/api.html#create_index|Index}
*
* @param {Object} [options={}] {@link https://pouchdb.com/api.html#query_index|FindOptions}
* @return {Promise<Array<ExtendedModel>>}
*/
static find (options = {}) {
let ExtendedModel = this
let { database } = this
options.selector = options.selector || {}
return database.find(options)
.then(results => results.docs.map(doc => new ExtendedModel(doc)))
.catch(error => Promise.reject(new InternalError(error.message, error.stack)))
}
/**
* get
*
* @static
*
* @description
* Retrieves a stored document and instantiate the extending class.
*
* @see {@link https://pouchdb.com/api.html#fetch_document|Query}
*
* @param {String} id
* @param {Object} [options={}] {@link https://pouchdb.com/api.html#fetch_document|GetOptions}
* @return {Promise<ExtendedModel>}
*/
static get (id, options = {}) {
let ExtendedModel = this
let { database } = this
return database.get(id, options)
.then(doc => new ExtendedModel(doc))
.catch(error => {
let { status, message, stack } = error
if (status === 404) {
return null
}
return Promise.reject(new InternalError(message, stack))
})
}
/**
* post
*
* @static
*
* @description
* Create a new document in the database and provide an instance of the extending class.
*
* @see {@link https://pouchdb.com/api.html#create_document|Post}
*
* @param {Object} data
* @param {String} [data._id] - Omitting this results in a new _id being generated.
* @param {String} [data._rev] - Required if updating an existing document
* @param {Object} [data._attachments]
* @return {Promise<ExtendedModel>}
*/
static post (data) {
let ExtendedModel = this
let { database } = this
let instance = new ExtendedModel(data)
let validation = instance.validate()
if (!validation.valid) {
return Promise.reject(new ValidationError(validation))
}
return database.post(instance)
.then(result => {
instance._id = result.id
instance._rev = result.rev
return instance
})
.catch(error => Promise.reject(new InternalError(error.message, error.stack)))
}
/**
* put
*
* @static
*
* @description
* Create or update a document in the database and provide an instance of the extending class.
*
* @see {@link https://pouchdb.com/api.html#create_document|Put}
*
* @param {Object} data
* @param {String} data._id
* @param {String} [data._rev] - Required if updating an existing document
* @param {Object} [data._attachments]
* @return {Promise<ExtendedModel>}
*/
static put (data) {
let ExtendedModel = this
return new ExtendedModel(data).put()
}
/**
* delete
*
* @static
*
* @description
* Delete a document in the database
*
* @see {@link https://pouchdb.com/api.html#delete_document|Delete}
*
* @param {(Object|String)} data
* @param {String} data._id
* @param {String} data._rev
* @return {Promise<Boolean>}
*/
static delete (data) {
let ExtendedModel = this
if (typeof data === 'string' || !data._rev) {
return this.get(data._id || data)
.then(doc => doc.delete())
}
return new ExtendedModel(data).delete()
}
/**
* createIndex
*
* @static
*
* @description
* Create an index on the database collection
*
* @see {@link https://pouchdb.com/api.html#create_index|Create Index}
*
* @param {Object} index - {@link https://pouchdb.com/api.html#create_index|Index}
* @return {Promise}
*/
static createIndex (index) {
let { database } = this
return this.database.createIndex(index)
}
/**
* getIndexes
*
* @static
*
* @description
* Get existing indices on the database collection
*
* @see {@link https://pouchdb.com/api.html#list_indexes|List Indexes}
*
* @return {Promise<Array<Index>>}
*/
static getIndexes () {
let { database } = this
return this.database.getIndexes()
.then(result => result.indexes)
}
/**
* createQuery
*
* @static
*
* @description
* Create a named query on the database collection
*
* @see {@link https://pouchdb.com/api.html#query_database|MapReduceQuery}
*
* @param {String} name
* @param {(Object|Function)} fn - {@link https://pouchdb.com/api.html#query_database|MapReduceQuery}
* @param {Function} fn.map
* @param {Function} [fn.reduce]
* @returns {Promise}
*/
static createQuery (name, fn) {
let { database } = this
if (!fn) {
return Promise.reject(new InvalidConfigurationError(`createQuery requires at least a map function`))
}
// Normalize input
if (typeof fn === 'function') {
fn = { map: fn }
} else if (typeof fn !== 'object') {
return Promise.reject(new InvalidConfigurationError(`createQuery requires at least a map function`))
}
return this.put({ _id: `_design/${name}`, views: { [name]: fn } })
.then(doc => doc._rev.startsWith('1') ? { result: 'created' } : { result: 'updated' })
}
/**
* close
*
* @static
*
* @description
* Close the database and replication connections and change feeds
*
* @return {Promise}
*/
static close () {
let { database, changes, sync } = this
let changesPromises = changes.map(change => change.cancel())
let syncPromises = sync.map(replication => replication.cancel())
return Promise.all([
database.close(),
Promise.all(changesPromises),
Promise.all(syncPromises),
]).catch(error => Promise.reject(new InternalError(error.message, error.stack)))
}
/**
* put
*
* @description
* Create or update a document in the database and provide an instance of the extending class.
*
* @see {@link https://pouchdb.com/api.html#create_document|Put}
*
* @return {Promise<ExtendedModel>}
*/
put () {
let { database, name: modelName } = this.constructor
let validation = this.validate()
if (!validation.valid) {
return Promise.reject(new ValidationError(validation))
}
return database.put(this)
.then(result => {
this._rev = result.rev
return this
})
.catch(error => {
let { status, message, stack } = error
// Database Conflict
if (status === 409) {
return this.constructor.get(this._id)
.then(doc => {
this._rev = doc._rev
return this.put()
})
}
return Promise.reject(new InternalError(message, stack))
})
}
/**
* delete
*
* @description
* Delete a document in the database
*
* @see {@link https://pouchdb.com/api.html#delete_document|Delete}
*
* @return {Promise<Boolean>}
*/
delete () {
let { database } = this.constructor
return database.remove(this)
.then(result => !!result.ok)
.catch(error => {
let { status, message, stack } = error
// Database Conflict
if (status === 409) {
return this.constructor.get(this._id)
.then(doc => {
this._rev = doc._rev
return this.delete()
})
}
return Promise.reject(new InternalError(message, stack))
})
}
/**
* getAttachment
*
* @description
* Get a document attachment from the database
*
* @see {@link https://pouchdb.com/api.html#get_attachment|Get Attachment}
*
* @param {String} name
* @param {Object} [options={}] {@link https://pouchdb.com/api.html#get_attachment|GetAttachmentOptions}
* @return {Promise<ExtendedModel>}
*/
getAttachment (name, options = {}) {
let { database } = this.constructor
return database.getAttachment(this._id, name, options)
.then(attachment => {
if (!this._attachments) {
this._attachments = {}
}
this._attachments[name] = attachment
return this
})
.catch(error => {
let { status, message, stack } = error
if (status === 404) {
if (!this._attachments) {
this._attachments = {}
}
this._attachments[name] = null
return this
}
return Promise.reject(new InternalError(message, stack))
})
}
/**
* putAttachment
*
* @description
* Put an attachment onto a document in the database
*
* @see {@link https://pouchdb.com/api.html#put_attachment|Put Attachment}
*
* @param {String} name
* @param {Object} attachment {@link https://pouchdb.com/api.html#put_attachment|Attachment}
* @param {String} attachment.content_type - mime type
* @param {Buffer} attachment.data
* @return {Promise<ExtendedModel>}
*/
putAttachment (name, attachment) {
let { content_type, data } = attachment
let { database } = this.constructor
return database.putAttachment(this._id, name, this._rev, data, content_type)
.then(result => {
this._rev = result.rev
if (!this._attachments) {
this._attachments = {}
}
this._attachments[name] = { content_type, data: data.toString('base64') }
return this
})
.catch(error => {
let { status, message, stack } = error
// Database conflict
if (status === 409) {
return this.constructor.get(this._id)
.then(doc => {
this._rev = doc._rev
return this.putAttachment(name, attachment)
})
}
return Promise.reject(new InternalError(message, stack))
})
}
/**
* deleteAttachment
*
* @description
* Delete an attachment from a document in the database
*
* @see {@link https://pouchdb.com/api.html#delete_attachment|Delete Attachment}
*
* @param {String} name
* @return {Promise<ExtendedModel>}
*/
deleteAttachment (name) {
let { database } = this.constructor
return database.removeAttachment(this._id, name, this._rev)
.then(result => {
this._rev = result.rev
if (this._attachments && this._attachments[name]) {
delete this._attachments[name]
}
return this
})
.catch(error => {
let { status, message, stack } = error
if (status === 409) {
return this.constructor.get(this._id)
.then(doc => {
this._rev = doc._rev
return this.deleteAttachment(name)
})
}
return Promise.reject(new InternalError(message, stack))
})
}
}
/**
* Exports
* @ignore
*/
module.exports = MixinModel