UNPKG

relution-sdk

Version:

Relution Software Development Kit for TypeScript and JavaScript

1,094 lines 191 kB
/* * @file livedata/SyncStore.ts * Relution SDK * * Created by Thomas Beckmann on 24.06.2015 * Copyright 2016 M-Way Solutions GmbH * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @module livedata */ /** */ "use strict"; var __extends = (this && this.__extends) || function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; var Q = require('q'); var _ = require('lodash'); var diag = require('../core/diag'); var objectid = require('../core/objectid'); var security = require('../security'); var web = require('../web'); var offline_1 = require('../web/offline'); var GetQuery_1 = require('../query/GetQuery'); var Store_1 = require('./Store'); var WebSqlStore_1 = require('./WebSqlStore'); var SyncContext_1 = require('./SyncContext'); var SyncEndpoint_1 = require('./SyncEndpoint'); var LiveDataMessage_1 = require('./LiveDataMessage'); var LiveDataTimestamp_1 = require('./LiveDataTimestamp'); var Model_1 = require('./Model'); var Collection_1 = require('./Collection'); /** * io of browser via script tag or via require socket.io-client, entirely optional. * * Notice, this module is entirely optional as the store may operate without it if socket * notifications are not used. * * @internal Not public API, exported for testing purposes only! */ exports.io = global['io'] || typeof require === 'function' && ((function requireSocketIo() { // here we are in an immediately invoked function requiring socket.io-client, if available try { return (global['io'] = require('socket.io-client')); } catch (error) { diag.debug.warn('optional socket.io-client module is not available: ' + error && error.message); } })()); /** * connects a Model/Collection to a Relution server. * * This will give you an online and offline store with live data updates. * * @example * * // The default configuration will save the complete model data as a json, * // and the offline change log to a local WebSql database, synchronize it * // trough REST calls with the server and receive live updates via a socket.io connection. * class MyCollection extends Relution.livedata.Collection {}; * MyCollection.prototype.model = MyModel; * MyCollection.prototype.url = 'http://myServer.io/myOrga/myApplication/myCollection'; * MyCollection.prototype.store = new Relution.livedata.SyncStore({ * useLocalStore: true, // (default) store the data for offline use * useSocketNotify: true, // (default) register at the server for live updates * useOfflineChanges: true // (default) allow changes to the offline data * }); */ var SyncStore = (function (_super) { __extends(SyncStore, _super); function SyncStore(options) { _super.call(this, options); this.endpoints = {}; /** * when set, indicates which entity caused a disconnection. * * <p> * This is set to an entity name to limit which entity may cause a change to online state again. * </p> * * @type {string} */ this.disconnectedEntity = 'all'; if (this.credentials) { this.credentials = _.clone(this.credentials); } if (this.localStoreOptions) { this.localStoreOptions = _.clone(this.localStoreOptions); } if (this.orderOfflineChanges) { this.orderOfflineChanges = _.clone(this.orderOfflineChanges); } if (this.useSocketNotify && typeof exports.io !== 'object') { diag.debug.warning('Socket.IO not present !!'); this.useSocketNotify = false; } } /** * overwritten to resolve relative URLs against the SyncStore#serverUrl. */ SyncStore.prototype.resolveUrl = function (url) { return web.resolveUrl(url, { serverUrl: this.serverUrl, application: this.application }); }; /** * binds the store to a target server when the first endpoint is created. * * @param urlRoot used to resolve the server to operate. */ SyncStore.prototype.initServer = function (urlRoot) { var serverUrl = web.resolveServer(urlRoot, { serverUrl: this.serverUrl }); if (!this.serverUrl) { var server = security.Server.getInstance(serverUrl); this.serverUrl = serverUrl; this.userUuid = server.authorization.name; if (this.localStoreOptions && !this.localStoreOptions.credentials) { // capture credentials for use by crypto stores this.localStoreOptions.credentials = _.defaults({ userUuid: this.userUuid }, server.credentials); } } else if (serverUrl !== this.serverUrl) { throw new Error('store is bound to server ' + this.serverUrl + ' already'); } }; SyncStore.prototype.checkServer = function (url, options) { var _this = this; diag.debug.assert(function () { return web.resolveServer(url, { serverUrl: _this.serverUrl }) === _this.serverUrl; }); if (security.Server.getInstance(this.serverUrl).authorization.name !== this.userUuid) { diag.debug.warn('user identity was changed, working offline until authorization is restored'); var error = new Error(); // invoke error callback, if any return options && this.handleError(options, error) || Q.reject(error); } return Q.resolve(url); }; SyncStore.prototype.initEndpoint = function (modelOrCollection, modelType) { var _this = this; var urlRoot = modelOrCollection.getUrlRoot(); var entity = modelOrCollection.entity; if (urlRoot && entity) { // get or create endpoint for this url this.initServer(urlRoot); urlRoot = this.resolveUrl(urlRoot); var endpoint_1 = this.endpoints[entity]; if (!endpoint_1) { diag.debug.info('Relution.livedata.SyncStore.initEndpoint: ' + entity); endpoint_1 = new SyncEndpoint_1.SyncEndpoint({ entity: entity, modelType: modelType, urlRoot: urlRoot, socketPath: this.socketPath, userUuid: this.userUuid }); this.endpoints[entity] = endpoint_1; endpoint_1.localStore = this.createLocalStore(endpoint_1); endpoint_1.priority = this.orderOfflineChanges && (_.lastIndexOf(this.orderOfflineChanges, endpoint_1.entity) + 1); this.createMsgCollection(); this.createTimestampCollection(); endpoint_1.socket = this.createSocket(endpoint_1, entity); endpoint_1.info = this.fetchServerInfo(endpoint_1); } else { // configuration can not change, must recreate store instead... diag.debug.assert(function () { return endpoint_1.urlRoot === urlRoot; }, 'can not change urlRoot, must recreate store instead!'); diag.debug.assert(function () { return endpoint_1.userUuid === _this.userUuid; }, 'can not change user identity, must recreate store instead!'); } return endpoint_1; } }; /** * @inheritdoc * * @internal API only to be called by Model constructor. */ SyncStore.prototype.initModel = function (model) { model.endpoint = this.initEndpoint(model, model.constructor); }; /** * @inheritdoc * * @internal API only to be called by Collection constructor. */ SyncStore.prototype.initCollection = function (collection) { collection.endpoint = this.initEndpoint(collection, collection.model); }; SyncStore.prototype.getEndpoint = function (modelOrCollection) { var endpoint = this.endpoints[modelOrCollection.entity]; if (endpoint) { diag.debug.assert(function () { // checks that modelOrCollection uses a model inheriting from the one of the endpoint var modelType = Collection_1.isCollection(modelOrCollection) ? modelOrCollection.model : modelOrCollection.constructor; return modelType === endpoint.modelType || modelType.prototype instanceof endpoint.modelType; }, 'wrong type of model!'); return endpoint; } }; SyncStore.prototype.createLocalStore = function (endpoint) { if (this.useLocalStore) { var entities = {}; entities[endpoint.entity] = endpoint.channel; var storeOption = { entities: entities }; if (this.localStoreOptions && typeof this.localStoreOptions === 'object') { storeOption = _.clone(this.localStoreOptions); storeOption.entities = entities; } return new this.localStore(storeOption); } }; /** * @description Here we save the changes in a Message local websql * @returns {*} */ SyncStore.prototype.createMsgCollection = function () { if (this.useOfflineChanges && !this.messages) { this.messages = new Collection_1.Collection(undefined, { model: LiveDataMessage_1.LiveDataMessageModel, store: new this.localStore(this.localStoreOptions) }); } return this.messages; }; SyncStore.prototype.createTimestampCollection = function () { if (this.useLocalStore && !this.timestamps) { this.timestamps = new Collection_1.Collection(undefined, { model: LiveDataTimestamp_1.LiveDataTimestampModel, store: new this.localStore(this.localStoreOptions) }); } return this.timestamps; }; SyncStore.prototype.createSocket = function (endpoint, name) { var _this = this; if (this.useSocketNotify && endpoint && endpoint.socketPath) { diag.debug.trace('Relution.livedata.SyncStore.createSocket: ' + name); // resource var connectVo = { 'force new connection': true }; var resource = endpoint.socketPath; // remove leading / connectVo.resource = (resource && resource.indexOf('/') === 0) ? resource.substr(1) : resource; if (this.socketQuery) { connectVo.query = this.socketQuery; } // socket endpoint.socket = exports.io.connect(endpoint.host, connectVo); endpoint.socket.on('connect', function () { (_this._bindChannel(endpoint, name) || Q.resolve(endpoint)).then(function (ep) { diag.debug.assert(function () { return ep === endpoint; }); return _this.onConnect(ep); }).done(); }); endpoint.socket.on('disconnect', function () { diag.debug.info('socket.io: disconnect'); return _this.onDisconnect(endpoint).done(); }); endpoint.socket.on(endpoint.channel, function (msg) { return _this.onMessage(endpoint, _this._fixMessage(endpoint, msg)); }); return endpoint.socket; } }; SyncStore.prototype._bindChannel = function (endpoint, name) { if (endpoint && endpoint.socket) { diag.debug.trace('Relution.livedata.SyncStore._bindChannel: ' + name); var channel = endpoint.channel; var socket = endpoint.socket; name = name || endpoint.entity; return this.getTimestamp(channel).then(function (time) { socket.emit('bind', { entity: name, channel: channel, time: time }); return Q.resolve(endpoint); }); } }; SyncStore.prototype.keyLastMessage = function (channel) { return '__' + channel + 'lastMesgTime'; }; // deprecated: use getTimestamp instead! SyncStore.prototype.getLastMessageTime = function (channel) { if (!this.lastMesgTime) { this.lastMesgTime = {}; } else if (this.lastMesgTime[channel] !== undefined) { return this.lastMesgTime[channel]; } // the | 0 below turns strings into numbers var time = offline_1.localStorage().getItem(this.keyLastMessage(channel)) || 0; this.lastMesgTime[channel] = time; return time; }; // deprecated: use setTimestamp instead! SyncStore.prototype.setLastMessageTime = function (channel, time) { if (!time) { offline_1.localStorage().removeItem(this.keyLastMessage(channel)); } else if (time > this.getLastMessageTime(channel)) { offline_1.localStorage().setItem(this.keyLastMessage(channel), time); } else { return this.lastMesgTime[channel]; } this.lastMesgTime[channel] = time; return time; }; SyncStore.prototype.getTimestampModel = function (channel) { var _this = this; if (this.timestamps) { if (!this.timestampsPromise) { // initially fetch all messages this.timestampsPromise = Q(this.timestamps.fetch()); } return this.timestampsPromise.then(function () { return _this.timestamps.get(channel) || _this.timestamps.add(new _this.timestamps.model({ channel: channel, timestamp: _this.getLastMessageTime(channel) }, { store: _this.timestamps.store })); }); } }; SyncStore.prototype.getTimestamp = function (channel) { var _this = this; var q = this.getTimestampModel(channel); if (!q) { return Q.resolve(this.getLastMessageTime(channel)); } this.timestampsPromise = q.then(function (model) { return model.attributes.timestamp; }).catch(function (err) { diag.debug.error('Relution.livedata.SyncStore.getTimestamp: ' + channel, err); return _this.getLastMessageTime(channel); }); return this.timestampsPromise; }; SyncStore.prototype.setTimestamp = function (channel, time) { var _this = this; var q = this.getTimestampModel(channel); if (!q) { return this.setLastMessageTime(channel, time); } this.timestampsPromise = q.then(function (model) { if (!time || time > model.attributes.timestamp) { return model.save({ timestamp: time }).thenResolve(time); } return model.attributes.timestamp; }).catch(function (err) { diag.debug.error('Relution.livedata.SyncStore.setTimestamp: ' + channel, err); return time; }).finally(function () { return _this.setLastMessageTime(channel, time); }); return this.timestampsPromise; }; SyncStore.prototype.onConnect = function (endpoint) { var _this = this; if (!endpoint.isConnected) { // when offline transmission is pending, need to wait for it to complete var q = Q.resolve(undefined); if (this.messagesPromise && this.messagesPromise.isPending()) { q = this.messagesPromise.catch(function (error) { return Q.resolve(undefined); }); } // sync server/client changes endpoint.isConnected = q.then(function () { // next we'll fetch server-side changes return _this.fetchChanges(endpoint).then(function () { // then send client-side changes if (_this.disconnectedEntity === 'all' || _this.disconnectedEntity === endpoint.entity) { // restart replaying of offline messages _this.messagesPromise = null; _this.disconnectedEntity = null; } return _this._sendMessages(); }).catch(function (error) { // catch without error indicates disconnection while going online if (!error) { // disconnected while sending offline changes return _this.onDisconnect(endpoint); } return Q.reject(error); }); }).finally(function () { // in the end, when connected still, fire an event informing client code if (endpoint.isConnected) { _this.trigger('connect:' + endpoint.channel); } }); } return endpoint.isConnected; }; SyncStore.prototype.onDisconnect = function (endpoint) { var _this = this; if (!endpoint.isConnected) { return Q.resolve(undefined); } endpoint.isConnected = null; if (!this.disconnectedEntity) { this.disconnectedEntity = 'all'; } return Q.fcall(function () { if (endpoint.socket && endpoint.socket.socket) { // consider calling endpoint.socket.disconnect() instead endpoint.socket.socket.onDisconnect(); } return undefined; }).finally(function () { if (!endpoint.isConnected) { _this.trigger('disconnect:' + endpoint.channel); } }); }; SyncStore.prototype._fixMessage = function (endpoint, msg) { var idAttribute = endpoint.modelType.prototype.idAttribute; diag.debug.assert(function () { return !!idAttribute; }, 'no idAttribute!'); if (msg.data && !msg.data[idAttribute] && msg.data._id) { msg.data[idAttribute] = msg.data._id; // server bug! } else if (!msg.data && msg.method === 'delete' && msg[idAttribute]) { msg.data = {}; msg.data[idAttribute] = msg[idAttribute]; // server bug! } return msg; }; SyncStore.prototype.onMessage = function (endpoint, msg) { var _this = this; // this is called by the store itself for a particular endpoint! if (!msg || !msg.method) { return Q.reject(new Error('no message or method given')); } var q; var channel = endpoint.channel; if (endpoint.localStore) { // first update the local store by forming a model and invoking sync var options = _.defaults({ store: endpoint.localStore }, this.localStoreOptions); var model = new endpoint.modelType(msg.data, _.extend({ parse: true }, options)); if (!model.id) { // code below will persist with auto-assigned id but this nevertheless is a broken record diag.debug.error('onMessage: ' + endpoint.entity + ' received data with no valid id performing ' + msg.method + '!'); } else { diag.debug.debug('onMessage: ' + endpoint.entity + ' ' + model.id + ' performing ' + msg.method); } q = endpoint.localStore.sync(msg.method, model, _.extend(options, { merge: msg.method === 'patch' })).then(function (result) { if (!msg.id || msg.id === model.id) { return result; } // id value was reassigned, delete record of old id var oldData = {}; oldData[model.idAttribute] = msg.id; var oldModel = new endpoint.modelType(oldData, options); diag.debug.debug('onMessage: ' + endpoint.entity + ' ' + model.id + ' reassigned from old record ' + oldModel.id); return endpoint.localStore.sync('delete', oldModel, options); }); } else { // just update all collections listening q = Q.resolve(msg); } // finally set the message time return q.then(function () { return Q.resolve(msg.time && _this.setTimestamp(channel, msg.time)).then(function () { // update all collections listening _this.trigger('sync:' + channel, msg); // SyncContext.onMessage return msg; }); }, function (error) { // not setting message time in error case // report error as event on store _this.trigger('error:' + channel, error, model); return msg; }); }; SyncStore.prototype.sync = function (method, model, options) { var _this = this; if (options === void 0) { options = {}; } diag.debug.trace('Relution.livedata.SyncStore.sync'); try { var endpoint = model.endpoint || this.getEndpoint(model); if (!endpoint) { throw new Error('no endpoint'); } if (Collection_1.isCollection(model)) { // collections can be filtered, etc. if (method === 'read' && !options.barebone) { var syncContext = options.syncContext; // sync can be called by SyncContext itself when paging results if (!syncContext) { // capture GetQuery options syncContext = new SyncContext_1.SyncContext(options, // dynamic options passed to fetch() implement UI filters, etc. model.options, // static options on collection implement screen-specific stuff this // static options of this store realize filtering client/server ); options.syncContext = syncContext; } if (model.syncContext !== syncContext) { // assign a different instance if (model.syncContext) { model.stopListening(this, 'sync:' + endpoint.channel); } model.listenTo(this, 'sync:' + endpoint.channel, _.bind(syncContext.onMessage, syncContext, this, model)); model.syncContext = syncContext; } } } else if (Model_1.isModel(model)) { // offline capability requires IDs for data if (!model.id) { if (method === 'create') { model.set(model.idAttribute, objectid.makeObjectID()); } else { throw new Error('no (valid) id: ' + model.id); } } } else { // something is really at odds here... throw new Error('target of sync is neither a model nor a collection!?!'); } // at this point the target server is known, check making sure the correct server is being hit var serverUrl = web.resolveServer(model.getUrlRoot(), { serverUrl: this.serverUrl }); if (serverUrl !== this.serverUrl) { throw new Error('store is bound to server ' + this.serverUrl); } var channel = endpoint.channel; return this.getTimestamp(channel).then(function (time) { try { // only send read messages if no other store can do this or for initial load if (method === 'read' && endpoint.localStore && time && !options.reset) { // read data from localStore and fetch changes remote var opts = _.clone(options); opts.store = endpoint.localStore; opts.entity = endpoint.entity; delete opts.success; delete opts.error; return endpoint.localStore.sync(method, model, opts).then(function (resp) { // backbone success callback alters the collection now resp = _this.handleSuccess(options, resp) || resp; if (endpoint.socket || options.fetchMode === 'local') { // no need to fetch changes as we got a websocket, that is either connected or attempts reconnection return resp; } // when we are disconnected, try to connect now if (!endpoint.isConnected) { return _this.fetchServerInfo(endpoint).then(function (info) { // trigger reconnection when disconnected var result; if (!endpoint.isConnected) { result = _this.onConnect(endpoint); } return result || info; }, function (xhr) { // trigger disconnection when disconnected var result; if (!xhr.statusCode && endpoint.isConnected) { result = _this.onDisconnect(endpoint); } return result || resp; }).thenResolve(resp); } // else... // load changes only (will happen AFTER success callback is invoked, // but returned promise will resolve only after changes were processed. return _this.fetchChanges(endpoint).catch(function (xhr) { if (!xhr.statusCode && endpoint.isConnected) { return _this.onDisconnect(endpoint) || resp; } // can not do much about it... _this.trigger('error:' + channel, xhr, model); return resp; }).thenResolve(resp); // caller expects original XHR response as changes body data is NOT compatible }, function () { // fall-back to loading full data set return _this._addMessage(method, model, options, endpoint); }); } // do backbone rest return _this._addMessage(method, model, options, endpoint); } catch (error) { return Q.reject(_this.handleError(options, error) || error); } }); } catch (error) { return Q.reject(this.handleError(options, error) || error); } }; SyncStore.prototype._addMessage = function (method, model, options, endpoint) { var _this = this; var changes = model.changedSinceSync; var data = null; var storeMsg = true; switch (method) { case 'update': case 'create': data = options.attrs || model.toJSON(); break; case 'patch': if (_.isEmpty(changes)) { return; } data = model.toJSON({ attrs: changes }); break; case 'delete': break; default: diag.debug.assert(function () { return method === 'read'; }, 'unknown method: ' + method); storeMsg = false; break; } var entity = model.entity || endpoint.entity; diag.debug.assert(function () { return model.entity === endpoint.entity; }); diag.debug.assert(function () { return entity.indexOf('~') < 0; }, 'entity name must not contain a ~ character!'); var msg = { _id: entity + '~' + model.id, id: model.id, method: method, data: data, // channel: endpoint.channel, // channel is hacked in by storeMessage(), we don't want to use this anymore priority: endpoint.priority, time: Date.now() }; var q = Q.resolve(msg); var qMessage; if (storeMsg) { // store and potentially merge message qMessage = this.storeMessage(endpoint, q); q = qMessage.then(function (message) { // in case of merging, this result could be different return message.attributes; }); } return q.then(function (msg2) { // pass in qMessage so that deletion of stored message can be scheduled return _this._emitMessage(endpoint, msg2, options, model, qMessage); }); }; SyncStore.prototype._emitMessage = function (endpoint, msg, options, model, qMessage) { var _this = this; var channel = endpoint.channel; var qAjax = this._ajaxMessage(endpoint, msg, options, model); var q = qAjax; if (qMessage) { // following takes care of offline change store q = q.then(function (data) { // success, remove message stored, if any return _this.removeMessage(endpoint, msg, qMessage).catch(function (error) { _this.trigger('error:' + channel, error, model); // can not do much about it... return data; }).thenResolve(data); // resolve again yielding data }, function (xhr) { // failure eventually caught by offline changes if (!xhr.statusCode && _this.useOfflineChanges) { // this seams to be only a connection problem, so we keep the message and call success return Q.resolve(msg.data); } else { // remove message stored and keep rejection as is return _this.removeMessage(endpoint, msg, qMessage).catch(function (error) { _this.trigger('error:' + channel, error, model); // can not do much about it... return xhr; }).thenReject(xhr); } }); } q = this._applyResponse(q, endpoint, msg, options, model); return q.finally(function () { // do some connection handling return qAjax.then(function () { // trigger reconnection when disconnected if (!endpoint.isConnected) { return _this.onConnect(endpoint); } }, function (xhr) { // trigger disconnection when disconnected if (!xhr.statusCode && endpoint.isConnected) { return _this.onDisconnect(endpoint); } }); }); }; SyncStore.prototype._ajaxMessage = function (endpoint, msg, options, model) { var _this = this; options = options || {}; delete options.xhr; // make sure not to use old value var url = options.url; if (!url) { url = endpoint.urlRoot; if (msg.id && msg.method !== 'create') { // add ID of model url += (url.charAt(url.length - 1) === '/' ? '' : '/') + msg.id; } if (msg.method === 'read' && Collection_1.isCollection(model)) { // add query of collection var collectionUrl = _.isFunction(model.url) ? model.url() : model.url; var queryIndex = collectionUrl.lastIndexOf('?'); var getQuery = new GetQuery_1.GetQuery().fromJSON(options); // currently only sortOrder can be supported as we require the initial data load to yield full dataset getQuery.limit = null; getQuery.offset = null; getQuery.filter = null; getQuery.fields = null; var getParams = getQuery.toQueryParams(); if (queryIndex >= 0) { url += collectionUrl.substr(queryIndex); if (getParams) { url += '&' + getParams; } } else { if (getParams) { url += '?' + getParams; } } } } // earliest point where target URL is known diag.debug.debug('ajaxMessage ' + msg.method + ' ' + url); var opts = { // must not take arbitrary options as these won't be replayed on reconnect url: url, attrs: msg.data, store: {}, credentials: options.credentials, // error propagation error: options.error }; // protect against wrong server and user identity diag.debug.assert(function () { return web.resolveServer(url, { serverUrl: _this.serverUrl }) === _this.serverUrl; }); if (security.Server.getInstance(this.serverUrl).authorization.name !== this.userUuid) { diag.debug.warn('user identity was changed, working offline until authorization is restored'); var error = new Error(); // invoke error callback, if any return this.handleError(opts, error) || Q.reject(error); } // actual ajax request via backbone.js return this.checkServer(url, opts).then(function () { return model.sync(msg.method, model, opts).finally(function () { // take over xhr resolving the options copy options.xhr = opts.xhr.xhr || opts.xhr; }); }); }; SyncStore.prototype._applyResponse = function (qXHR, endpoint, msg, options, model) { var _this = this; // var channel = endpoint.channel; var clientTime = new Date().getTime(); return qXHR.then(function (data) { // delete on server does not respond a body if (!data && msg.method === 'delete') { data = msg.data; } // update local store state if (data) { // no data if server asks not to alter state // this.setTimestamp(channel, msg.time); var promises = []; var dataIds; // model id -> attributes data if (msg.method !== 'read') { promises.push(_this.onMessage(endpoint, _this._fixMessage(endpoint, data === msg.data ? msg : _.defaults({ data: data // just accepts new data }, msg)))); } else if (Collection_1.isCollection(model) && Array.isArray(data)) { // synchronize the collection contents with the data read var syncIds = {}; model.models.forEach(function (m) { syncIds[m.id] = m; }); dataIds = {}; data.forEach(function (d) { if (d) { var id = d[endpoint.modelType.prototype.idAttribute] || d._id; dataIds[id] = d; var m = syncIds[id]; if (m) { // update the item delete syncIds[id]; // so that it is deleted below if (!_.isEqual(_.pick.call(m, m.attributes, Object.keys(d)), d)) { // above checked that all attributes in d are in m with equal values and found some mismatch promises.push(_this.onMessage(endpoint, _this._fixMessage(endpoint, { id: id, method: 'update', time: msg.time, data: d }))); } } else { // create the item promises.push(_this.onMessage(endpoint, _this._fixMessage(endpoint, { id: id, method: 'create', time: msg.time, data: d }))); } } }); Object.keys(syncIds).forEach(function (id) { // delete the item var m = syncIds[id]; promises.push(_this.onMessage(endpoint, _this._fixMessage(endpoint, { id: id, method: 'delete', time: msg.time, data: m.attributes }))); }); } else { // trigger an update to load the data read var array = Array.isArray(data) ? data : [data]; for (var i = 0; i < array.length; i++) { data = array[i]; if (data) { promises.push(_this.onMessage(endpoint, _this._fixMessage(endpoint, { id: data[endpoint.modelType.prototype.idAttribute] || data._id, method: 'update', time: msg.time, data: data }))); } } } return Q.all(promises).then(function () { // delayed till operations complete if (!dataIds) { return data; } diag.debug.assert(function () { return Collection_1.isCollection(model); }); // when collection was updated only pass data of models that were synced on to the success callback, // as the callback will set the models again causing our sorting and filtering to be without effect. var response = []; var models = Collection_1.isCollection(model) ? model.models : [model]; for (var i = models.length; i-- > 0;) { var m = models[i]; if (dataIds[m.id]) { response.push(m.attributes); delete dataIds[m.id]; if (dataIds.length <= 0) { break; } } } return response.reverse(); }); } }).then(function (response) { var qTime; if (msg.method === 'read' && Collection_1.isCollection(model)) { // TODO: extract Date header from options.xhr instead of using clientTime qTime = _this.setTimestamp(endpoint.channel, clientTime); } else { qTime = Q.resolve(undefined); } return qTime.then(function () { // invoke success callback, if any return _this.handleSuccess(options, response) || response; }); }, function (error) { // invoke error callback, if any return _this.handleError(options, error) || Q.reject(error); }); }; SyncStore.prototype.fetchChanges = function (endpoint, force) { var _this = this; if (force === void 0) { force = false; } var channel = endpoint.channel; if (!endpoint.urlRoot || !channel) { return Q.resolve(undefined); } var now = Date.now(); var promise = endpoint.promiseFetchingChanges; if (promise && !force) { if (promise.isPending() || now - endpoint.timestampFetchingChanges < 1000) { // reuse existing eventually completed request for changes diag.debug.warning(channel + ' skipping changes request...'); return promise; } } return this.getTimestamp(channel).then(function (time) { if (!time) { diag.debug.error(channel + ' can not fetch changes at this time!'); return promise || Q.resolve(undefined); } // initiate a new request for changes diag.debug.info(channel + ' initiating changes request...'); var changes = new _this.messages.constructor(); promise = _this.checkServer(endpoint.urlRoot + 'changes/' + time).then(function (url) { return Q(changes.fetch({ url: url, store: {}, success: function (model, response, options) { return response || options.xhr; } })).then(function () { if (changes.models.length > 0) { return Q.all(changes.map(function (change) { var msg = change.attributes; return _this.onMessage(endpoint, _this._fixMessage(endpoint, msg)); })); } else { // following should use server time! return _this.setTimestamp(channel, now); } }).thenResolve(changes); }); endpoint.promiseFetchingChanges = promise; endpoint.timestampFetchingChanges = now; return promise; }); }; SyncStore.prototype.fetchServerInfo = function (endpoint) { var _this = this; var now = Date.now(); var promise = endpoint.promiseFetchingServerInfo; if (promise) { if (promise.isPending() || now - endpoint.timestampFetchingServerInfo < 1000) { // reuse existing eventually completed request for changes diag.debug.warning(endpoint.channel + ' skipping info request...'); return promise; } } var info = new Model_1.Model(); var url = endpoint.urlRoot; if (url.charAt((url.length - 1)) !== '/') { url += '/'; } promise = this.checkServer(url + 'info').then(function (url) { return Q(info.fetch(({ url: url, success: function (model, response, options) { return response || options.xhr; } }))).then(function () { //@todo why we set a server time here ? return _this.getTimestamp(endpoint.channel).then(function (time) { if (!time && info.get('time')) { return _this.setTimestamp(endpoint.channel, info.get('time')); } return time; }); }).then(function () { if (!endpoint.socketPath && info.get('socketPath')) { endpoint.socketPath = info.get('socketPath'); var name = info.get('entity') || endpoint.entity; if (_this.useSocketNotify) { endpoint.socket = _this.createSocket(endpoint, name); } } return info; }); }); endpoint.promiseFetchingServerInfo = promise; endpoint.timestampFetchingServerInfo = now; return promise; }; /** * called when an offline change was sent to the remote server. * * <p> * May be overwritten to alter change message error handling behavior. The default implementation will attempt * reloading the server data for restoring the client state such that it reflects the server state. When this * succeeded, the offline change is effectively reverted and the change message is dropped. * </p> * <p> * An overwritten implementation may decided whether to revert failed changes based on the error reported. * </p> * <p> * Notice, the method is not called when the offline change failed due to a connectivity issue. * </p> * * @param error reported by remote server. * @param message change reported, attributes of type LiveDataMessage. * @param options context information required to access the data locally as well as remotely. * @return {any} Promise indicating success to drop the change message and proceed with the next change, or * rejection indicating the change message is kept and retried later on. */ SyncStore.prototype.processOfflineMessageResult = function (error, message, options) { var _this = this; if (!error) { // message was processed successfully if (!this.useSocketNotify) { // when not using sockets, fetch changes now var endpoint = this.endpoints[options.entity]; if (endpoint) { // will pull the change caused by the offline message and update the message time, // so that we avoid the situation where the change caused by replaying the offline // change results in a conflict later on... return this.fetchChanges(endpoint, true); } } return Q.resolve(message); } // failed, eventually undo the modifications stored if (!options.localStore) { return Q.reject(error); } // revert modification by reloading data var modelType = options.modelType || Model_1.Model; var model = new modelType(message.get('data'), { entity: options.entity }); model.id = message.get('method') !== 'create' && message.get('id'); var triggerError = function () { // inform client application of the offline changes error var channel = message.get('channel'); diag.debug.error('Relution.livedata.SyncStore.processOfflineMessageResult: triggering error for channel ' + channel + ' on store', error); if (!options.silent) { _this.trigger('error:' + channel, error, model); } }; var localOptions = { // just affect local store store: options.localStore }; var remoteOptions = { urlRoot: options.urlRoot, store: {} // really go to remote server }; if (model.id) { remoteOptions.url = remoteOptions.urlRoot + (remoteOptions.urlRoot.charAt(remoteOptions.urlRoot.length - 1) === '/' ? '' : '/') + model.id; } else { // creation failed, just delete locally diag.debug.assert(function () { return message.get('method') === 'create'; }); return model.destroy(localOptions).finally(triggerError); } return model.fetch(remoteOptions).then(function (data) { // original request failed and the code above reloaded the data to revert the local modifications, which succeeded... return model.save(data, localOptions).finally(triggerError); }, function (fetchResp) { // original request failed and the code above tried to revert the local modifications by reloading the data, which failed as well... var statusCode = fetchResp && fetchResp.statusCode; switch (statusCode) { case 404: // NOT FOUND case 401: // UNAUTHORIZED case 410: // ...because the item is gone by now, maybe someone else changed it to be deleted return model.destroy(localOptions); // silent regarding triggerError default: return Q.reject(fetchResp).finally(triggerError); } }); }; /** * feeds pending offline #messages to the remote server. * * <p> * Due to client code setting up models one at a time, this method is called multiple times during i