relution-sdk
Version:
Relution Software Development Kit for TypeScript and JavaScript
1,094 lines • 191 kB
JavaScript
/*
* @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