UNPKG

ember-pouch

Version:
528 lines (458 loc) 16.7 kB
import { assert } from '@ember/debug'; import { isEmpty } from '@ember/utils'; import { all, defer } from 'rsvp'; import { get } from '@ember/object'; import { getOwner } from '@ember/application'; import { bind } from '@ember/runloop'; import { on } from '@ember/object/evented'; import { classify, camelize } from '@ember/string'; import DS from 'ember-data'; import { pluralize } from 'ember-inflector'; //import BelongsToRelationship from 'ember-data/-private/system/relationships/state/belongs-to'; import { extractDeleteRecord, shouldSaveRelationship, configFlagDisabled } from '../utils'; //BelongsToRelationship.reopen({ // findRecord() { // return this._super().catch(() => { // //not found: deleted // this.clear(); // }); // } //}); export default DS.RESTAdapter.extend({ fixDeleteBug: true, coalesceFindRequests: false, // The change listener ensures that individual records are kept up to date // when the data in the database changes. This makes ember-data 2.0's record // reloading redundant. shouldReloadRecord: function () { return false; }, shouldBackgroundReloadRecord: function () { return false; }, _onInit : on('init', function() { this._startChangesToStoreListener(); }), _startChangesToStoreListener: function() { var db = this.get('db'); if (db && !this.changes) { // only run this once var onChangeListener = bind(this, 'onChange'); this.set('onChangeListener', onChangeListener); this.changes = db.changes({ since: 'now', live: true, returnDocs: false }); this.changes.on('change', onChangeListener); } }, _stopChangesListener: function() { if (this.changes) { var onChangeListener = this.get('onChangeListener'); this.changes.removeListener('change', onChangeListener); this.changes.cancel(); this.changes = undefined; } }, changeDb: function(db) { this._stopChangesListener(); var store = this.store; var schema = this._schema || []; for (var i = 0, len = schema.length; i < len; i++) { store.unloadAll(schema[i].singular); } this._schema = null; this.set('db', db); this._startChangesToStoreListener(); }, onChange: function (change) { // If relational_pouch isn't initialized yet, there can't be any records // in the store to update. if (!this.get('db').rel) { return; } var obj = this.get('db').rel.parseDocID(change.id); // skip changes for non-relational_pouch docs. E.g., design docs. if (!obj.type || !obj.id || obj.type === '') { return; } var store = this.store; if (this.waitingForConsistency[change.id]) { let promise = this.waitingForConsistency[change.id]; delete this.waitingForConsistency[change.id]; if (change.deleted) { promise.reject("deleted"); } else { promise.resolve(this._findRecord(obj.type, obj.id)); } return; } try { store.modelFor(obj.type); } catch (e) { // The record refers to a model which this version of the application // does not have. return; } var recordInStore = store.peekRecord(obj.type, obj.id); if (!recordInStore) { // The record hasn't been loaded into the store; no need to reload its data. if (this.createdRecords[obj.id]) { delete this.createdRecords[obj.id]; } else { this.unloadedDocumentChanged(obj); } return; } if (!recordInStore.get('isLoaded') || recordInStore.get('rev') === change.changes[0].rev || recordInStore.get('hasDirtyAttributes')) { // The record either hasn't loaded yet or has unpersisted local changes. // In either case, we don't want to refresh it in the store // (and for some substates, attempting to do so will result in an error). // We also ignore the change if we already have the latest revision return; } if (change.deleted) { if (this.fixDeleteBug) { recordInStore._internalModel.transitionTo('deleted.saved');//work around ember-data bug } else { store.unloadRecord(recordInStore); } } else { recordInStore.reload(); } }, unloadedDocumentChanged: function(/* obj */) { /* * For performance purposes, we don't load records into the store that haven't previously been loaded. * If you want to change this, subclass this method, and push the data into the store. e.g. * * let store = this.get('store'); * let recordTypeName = this.getRecordTypeName(store.modelFor(obj.type)); * this.get('db').rel.find(recordTypeName, obj.id).then(function(doc){ * store.pushPayload(recordTypeName, doc); * }); */ }, willDestroy: function() { this._stopChangesListener(); }, init() { this._indexPromises = []; this.waitingForConsistency = {}; this.createdRecords = {}; }, _indexPromises: null, _init: function (store, type, indexPromises) { var self = this, recordTypeName = this.getRecordTypeName(type); if (!this.get('db') || typeof this.get('db') !== 'object') { throw new Error('Please set the `db` property on the adapter.'); } if (!get(type, 'attributes').has('rev')) { var modelName = classify(recordTypeName); throw new Error('Please add a `rev` attribute of type `string`' + ' on the ' + modelName + ' model.'); } this._schema = this._schema || []; var singular = recordTypeName; var plural = pluralize(recordTypeName); // check that we haven't already registered this model for (var i = 0, len = this._schema.length; i < len; i++) { var currentSchemaDef = this._schema[i]; if (currentSchemaDef.singular === singular) { return all(this._indexPromises); } } var schemaDef = { singular: singular, plural: plural }; if (type.documentType) { schemaDef['documentType'] = type.documentType; } let config = getOwner(this).resolveRegistration('config:environment'); // else it's new, so update this._schema.push(schemaDef); // check all the subtypes // We check the type of `rel.type`because with ember-data beta 19 // `rel.type` switched from DS.Model to string var rels = [];//extra array is needed since type.relationships/byName return a Map that is not iterable type.eachRelationship((_relName, rel) => rels.push(rel)); let rootCall = indexPromises == undefined; if (rootCall) { indexPromises = []; } for (let rel of rels) { if (rel.kind !== 'belongsTo' && rel.kind !== 'hasMany') { // TODO: support inverse as well continue; // skip } var relDef = {}, relModel = (typeof rel.type === 'string' ? store.modelFor(rel.type) : rel.type); if (relModel) { let includeRel = true; if (!('options' in rel)) rel.options = {}; if (typeof(rel.options.async) === "undefined") { rel.options.async = config.emberPouch && !isEmpty(config.emberPouch.async) ? config.emberPouch.async : true;//default true from https://github.com/emberjs/data/pull/3366 } let options = Object.create(rel.options); if (rel.kind === 'hasMany' && !shouldSaveRelationship(self, rel)) { let inverse = type.inverseFor(rel.key, store); if (inverse) { if (inverse.kind === 'belongsTo') { indexPromises.push(self.get('db').createIndex({index: { fields: ['data.' + inverse.name, '_id'] }})); if (options.async) { includeRel = false; } else { options.queryInverse = inverse.name; } } } } if (includeRel) { relDef[rel.kind] = { type: self.getRecordTypeName(relModel), options: options }; if (!schemaDef.relations) { schemaDef.relations = {}; } schemaDef.relations[rel.key] = relDef; } self._init(store, relModel, indexPromises); } } this.get('db').setSchema(this._schema); if (rootCall) { this._indexPromises = this._indexPromises.concat(indexPromises); return all(indexPromises).then(() => { this._indexPromises = this._indexPromises.filter(x => !indexPromises.includes(x)); }); } }, _recordToData: function (store, type, record) { var data = {}; // Though it would work to use the default recordTypeName for modelName & // serializerKey here, these uses are conceptually distinct and may vary // independently. var modelName = type.modelName || type.typeKey; var serializerKey = camelize(modelName); var serializer = store.serializerFor(modelName); serializer.serializeIntoHash( data, type, record, {includeId: true} ); data = data[serializerKey]; // ember sets it to null automatically. don't need it. if (data.rev === null) { delete data.rev; } return data; }, /** * Return key that conform to data adapter * ex: 'name' become 'data.name' */ _dataKey: function(key) { var dataKey ='data.' + key; return ""+ dataKey + ""; }, /** * Returns the modified selector key to comform data key * Ex: selector: {name: 'Mario'} wil become selector: {'data.name': 'Mario'} */ _buildSelector: function(selector) { var dataSelector = {}; var selectorKeys = []; for (var key in selector) { if(selector.hasOwnProperty(key)){ selectorKeys.push(key); } } selectorKeys.forEach(function(key) { var dataKey = this._dataKey(key); dataSelector[dataKey] = selector[key]; }.bind(this)); return dataSelector; }, /** * Returns the modified sort key * Ex: sort: ['series'] will become ['data.series'] * Ex: sort: [{series: 'desc'}] will became [{'data.series': 'desc'}] */ _buildSort: function(sort) { return sort.map(function (value) { var sortKey = {}; if (typeof value === 'object' && value !== null) { for (var key in value) { if(value.hasOwnProperty(key)){ sortKey[this._dataKey(key)] = value[key]; } } } else { return this._dataKey(value); } return sortKey; }.bind(this)); }, /** * Returns the string to use for the model name part of the PouchDB document * ID for records of the given ember-data type. * * This method uses the camelized version of the model name in order to * preserve data compatibility with older versions of ember-pouch. See * pouchdb-community/ember-pouch#63 for a discussion. * * You can override this to change the behavior. If you do, be aware that you * need to execute a data migration to ensure that any existing records are * moved to the new IDs. */ getRecordTypeName(type) { return camelize(type.modelName); }, findAll: async function(store, type /*, sinceToken */) { // TODO: use sinceToken await this._init(store, type); return this.get('db').rel.find(this.getRecordTypeName(type)); }, findMany: async function(store, type, ids) { await this._init(store, type); return this.get('db').rel.find(this.getRecordTypeName(type), ids); }, findHasMany: async function(store, record, link, rel) { await this._init(store, record.type); let inverse = record.type.inverseFor(rel.key, store); if (inverse && inverse.kind === 'belongsTo') { return this.get('db').rel.findHasMany(camelize(rel.type), inverse.name, record.id); } else { let result = {}; result[pluralize(rel.type)] = []; return result; //data; } }, query: async function(store, type, query) { await this._init(store, type); var recordTypeName = this.getRecordTypeName(type); var db = this.get('db'); var queryParams = { selector: this._buildSelector(query.filter) }; if (!isEmpty(query.sort)) { queryParams.sort = this._buildSort(query.sort); } if (!isEmpty(query.limit)) { queryParams.limit = query.limit; } if (!isEmpty(query.skip)) { queryParams.skip = query.skip; } let pouchRes = await db.find(queryParams); return db.rel.parseRelDocs(recordTypeName, pouchRes.docs); }, queryRecord: async function(store, type, query) { let results = await this.query(store, type, query); let recordType = this.getRecordTypeName(type); let recordTypePlural = pluralize(recordType); if(results[recordTypePlural].length > 0){ results[recordType] = results[recordTypePlural][0]; } else { results[recordType] = null; } delete results[recordTypePlural]; return results; }, /** * `find` has been deprecated in ED 1.13 and is replaced by 'new store * methods', see: https://github.com/emberjs/data/pull/3306 * We keep the method for backward compatibility and forward calls to * `findRecord`. This can be removed when the library drops support * for deprecated methods. */ find: function (store, type, id) { return this.findRecord(store, type, id); }, findRecord: async function (store, type, id) { await this._init(store, type); var recordTypeName = this.getRecordTypeName(type); return this._findRecord(recordTypeName, id); }, async _findRecord(recordTypeName, id) { let payload = await this.get('db').rel.find(recordTypeName, id); // Ember Data chokes on empty payload, this function throws // an error when the requested data is not found if (typeof payload === 'object' && payload !== null) { var singular = recordTypeName; var plural = pluralize(recordTypeName); var results = payload[singular] || payload[plural]; if (results && results.length > 0) { return payload; } } if (configFlagDisabled(this, 'eventuallyConsistent')) throw new Error("Document of type '" + recordTypeName + "' with id '" + id + "' not found."); else return this._eventuallyConsistent(recordTypeName, id); }, //TODO: cleanup promises on destroy or db change? waitingForConsistency: null, _eventuallyConsistent: function(type, id) { let pouchID = this.get('db').rel.makeDocID({type, id}); let defered = defer(); this.waitingForConsistency[pouchID] = defered; return this.get('db').rel.isDeleted(type, id).then(deleted => { //TODO: should we test the status of the promise here? Could it be handled in onChange already? if (deleted) { delete this.waitingForConsistency[pouchID]; throw new Error("Document of type '" + type + "' with id '" + id + "' is deleted."); } else if (deleted === null) { return defered.promise; } else { assert('Status should be existing', deleted === false); //TODO: should we reject or resolve the promise? or does JS GC still clean it? if (this.waitingForConsistency[pouchID]) { delete this.waitingForConsistency[pouchID]; return this._findRecord(type, id); } else { //findRecord is already handled by onChange return defered.promise; } } }); }, createdRecords: null, createRecord: async function(store, type, record) { await this._init(store, type); var data = this._recordToData(store, type, record); let rel = this.get('db').rel; let id = data.id; if (!id) { id = data.id = rel.uuid(); } this.createdRecords[id] = true; let typeName = this.getRecordTypeName(type); try { let saved = await rel.save(typeName, data); Object.assign(data, saved); let result = {}; result[pluralize(typeName)] = [data]; return result; } catch(e) { delete this.createdRecords[id]; throw e; } }, updateRecord: async function (store, type, record) { await this._init(store, type); var data = this._recordToData(store, type, record); let typeName = this.getRecordTypeName(type); let saved = await this.get('db').rel.save(typeName, data); Object.assign(data, saved);//TODO: could only set .rev let result = {}; result[pluralize(typeName)] = [data]; return result; }, deleteRecord: async function (store, type, record) { await this._init(store, type); var data = this._recordToData(store, type, record); return this.get('db').rel.del(this.getRecordTypeName(type), data) .then(extractDeleteRecord); } });