Source: offline/sqlrest.js

/*
 * @author  Andrea Jonus <andrea.jonus@caffeina.com>
 */

exports.config = _.extend({
	ttl: 30,
	remoteAdapter: 'rest',
	localAdapter: 'sql',
	syncTableName: '__sync',
	infoTableName: '__info'
}, (Alloy.CFG.T && Alloy.CFG.T.offline) ? Alloy.CFG.T.offline.sqlrest : {});

var SQLite = require('T/sqlite');
var Util = require('T/util');
var Q = require('T/ext/q');

var LOGNAME = 'Offline/SQLREST';

var TABLES = {
	sync: {
		collection_name: exports.config.syncTableName,
		columns: {
			id: 'INTEGER PRIMARY KEY',
			timestamp: 'INTEGER NOT NULL',
			m_id: 'TEXT',
			m_table: 'TEXT',
			file_name: 'TEXT',
			method: 'TEXT',
			model: 'TEXT',
			options: 'TEXT'
		}
	},
	info: {
		collection_name: exports.config.infoTableName,
		columns: {
			id: 'INTEGER PRIMARY KEY',
			m_id: 'TEXT',
			m_table: 'TEXT',
			file_name: 'TEXT',
			offline: 'INTEGER',
			timestamp: 'INTEGER'
		}
	}
};

var Local = null;
var Remote = null;

var DB = new SQLite('_alloy_');

/** Initialize the tables for sync and timestamp references */
function SQLREST(model) {
	// Create the utility tables
	_.each(TABLES, function(table) {
		DB.run('CREATE TABLE IF NOT EXISTS ' + table.collection_name + '(' + _.map(table.columns, function(type, key) {
			return key + ' ' + type;
		}).join(', ') + ');');
	});

	this.model = model;
	this.config = _.extend({}, exports.config, model.config.adapter);

	// Add this model to the event listener queue
	if (Alloy.Globals.offline_models == null) Alloy.Globals.offline_models = [];
	if (Alloy.Globals.offline_models.indexOf(this.config.file_name) < 0) Alloy.Globals.offline_models.push(this.config.file_name);

	// Initialize the event listener if needed
	if (Alloy.Globals.offline_listener == null) {
		Alloy.Globals.offline_listener = function(e) {
			if (!e.online || Alloy.Globals.offline_handling) return;
			Alloy.Globals.offline_handling = true;
			var stopped = [];
			var subscribed = Alloy.Globals.offline_models || [];

			_.reduce(_.map(subscribed, function(name) {
				var model = Alloy.createModel(name);

				return Q.promise(function(resolve, reject) {
					model._push()
					.then(resolve)
					.catch(resolve);
				});
			}), Q.when, Q())
			.finally(function() {
				Alloy.Globals.offline_handling = false;
			});
		};
		Ti.Network.addEventListener('change', Alloy.Globals.offline_listener);
	}

	Local = require('alloy/sync/' + this.config.localAdapter);
	Remote = require('alloy/sync/' + this.config.remoteAdapter);
}

function deepClone(object) {
	var clone = _.clone(object);
	_.each(clone, function(value, key) {
		if (_.isObject(value)) {
			clone[key] = deepClone(value);
		}
	});
	return clone;
}

function stringifyResponse(obj) {
	var new_obj = deepClone(obj);

	_.each(new_obj, function(val, key) {
		if (_.isObject(val)) new_obj[key] = JSON.stringify(val);
	});

	return new_obj;
}

function parseResponse(obj) {
	var new_obj = deepClone(obj);

	_.each(new_obj, function(val, key) {
		if (_.isString(val)) {
			try {
				new_obj[key] = JSON.parse(val);
			} catch(err) {
				new_obj[key] = val;
			}
		}
	});

	return new_obj;
}

/** Get the info table row for this model */
function getInfoForModel(model) {
	return DB.table(exports.config.infoTableName)
	.where({
		m_id: String(model.id),
		m_table: model.config.adapter.collection_name
	})
	.select()
	.single();
}

/** Save the timestamp and offline status for a model */
function saveModelInfo(id, config, offline) {
	// Logger.debug('Saving ' + config.collection_name + '/' + id + ' offline ' + value + '...');
	if (getInfoForModel({ id: id, config: {adapter: { collection_name : config.collection_name }}}) != null) {
		DB.table(config.infoTableName)
		.where({
			m_id: String(id),
			m_table: config.collection_name
		})
		.update({
			offline: ~~offline,
			timestamp: Util.now()
		})
		.run();
	} else {
		DB.table(config.infoTableName)
		.insert({
			m_id: String(id),
			m_table: config.collection_name,
			file_name: config.file_name,
			offline: ~~offline,
			timestamp: Util.now()
		})
		.run();
	}
}

/** Remove the info table row for this model */
function removeModelInfo(model) {
	return DB.table(exports.config.infoTableName)
	.where({
		m_id: String(model.id),
		m_table: model.config.adapter.collection_name
	})
	.delete()
	.run();
}

/** Get all the sync table rows, or the rows for a model. */
function getSyncs(model) {
	var query = DB.table(exports.config.syncTableName);

	if (model != null) {
		if (model instanceof Backbone.Model) {
			query.where({
				m_id: String(model.id),
				m_table: model.config.adapter.collection_name
			});
		} else {
			query.where({
				m_table: model.config.adapter.collection_name
			});
		}
	}

	return query
	.select()
	.all();
}

/** Add a row in the sync table for the call to a method for a model. */
function addSyncForModel(method, model, opt) {
	var mId = String(model.id);
	var mTable = model.config.adapter.collection_name;

	// Insert the new sync call
	DB.table(exports.config.syncTableName)
	.insert({
		timestamp: Util.now(),
		m_id: mId,
		m_table: mTable,
		method: method,
		file_name: model.config.adapter.file_name,
		model: JSON.stringify(model ? model.toJSON() : {}),
		options: JSON.stringify(opt || {})
	})
	.run();
}

/** Remove a row from the sync table */
function removeSyncRow(row) {
	DB.table(exports.config.syncTableName)
	.where(row)
	.delete()
	.run();
}

/** Postponed sync call for models in the sync table */
function postponedSync(method, model, opt) {
	return Q.promise(function(resolve, reject) {
		Remote.sync(method, model, _.extend({}, opt, {
			success: function(response) {
				resolve({ message: 'Success on postponed call to ' + method + ' for ' + model.id });
			},
			error: function(err) {
				if (Ti.Network.online && err.code != 0) {
					// Unrecoverable error
					resolve({ message: 'Could not ' + method + ' the remote model for ' + model.id + ': ' + err });
				} else {
					// Network offline or recoverable error
					reject({ message: 'Recoverable error on ' + method + ' call: ' + err });
				}
			}
		}));
	});
}

/** Get a promise for the local update of a model */
function updateLocal(model, attributes, opt) {
	attributes = attributes || model.toJSON();

	return Q.promise(function(resolve, reject) {
		Local.sync('update', model.clone().set(stringifyResponse(attributes)), _.extend({}, opt, {
			success: resolve,
			error: reject
		}));
	});
}

/** Return true if this model is present in the sync table and valid for local fetch operations */
SQLREST.prototype._isLocalValid = function() {
	var self = this;
	var info_row = getInfoForModel(self.model);

	return info_row != null && ((Boolean(info_row.offline) && info_row.timestamp != null) || info_row.timestamp + self.config.ttl > Util.now());
};

/** Push the enqueued changes for this model */
SQLREST.prototype._push = function() {
	var self = this;

	var postponed = getSyncs(self.model);

	return _.reduce(_.map(postponed, function(row) {
		var new_model = null;
		var model_to_sync = row.model;

		if (self.model instanceof Backbone.Model) {
			new_model = self.model.clone();
		} else {
			new_model = new self.model.model();
		}

		try {
			new_model.set(JSON.parse(row.model));
		} catch(err) {
			Ti.API.error(LOGNAME + ' error while parsing model data: ', err);
		}

		return postponedSync(row.method, new_model, row.options)
		.then(function(response) {
			Ti.API.debug(LOGNAME + ': ' + response.message);

			removeSyncRow(row);
		});
	}), Q.when, Q())
	.catch(function(err) {
		Ti.API.error(LOGNAME + ': ' + err.message);
	});
};

/** Get the remote version of a model/collection */
SQLREST.prototype._pull = function(opt) {
	var self = this;

	return Q.promise(function(resolve, reject) {
		Remote.sync('read', self.model, _.extend({}, opt, {
			cache: false,
			success: resolve,
			error: reject
		}));
	});
};

SQLREST.prototype._update = function(opt) {
	return updateLocal(this.model, null, opt);
};

SQLREST.prototype._destroy = function(opt) {
	var self = this;

	if (self.model instanceof Backbone.Collection) {
		// Call "delete" on every element if this is a Collection
		return Q.all(self.model.map(function(mod) {
			return Q.promise(function(resolve, reject) {
				// TODO try/catch?
				removeModelInfo(mod);
				_.each(getSyncs(mod), removeSyncRow);

				Local.sync('delete', mod, _.extend({}, opt, {
					success: resolve,
					error: reject
				}));
			});
		}));
	} else {
		// Simply call "delete" on the Model otherwise
		return Q.promise(function(resolve, reject) {
			// TODO try/catch?
			removeModelInfo(self.model);
			_.each(getSyncs(self.model), removeSyncRow);

			Local.sync('delete', self.model, _.extend({}, opt, {
				success: resolve,
				error: reject
			}));
		});
	}
};

/** Persist a model/collection in the local storage of choice */
SQLREST.prototype._persist = function(response) {
	var model = this.model;
	var config = this.config;

	var promises = null;

	if (model instanceof Backbone.Model) {
		promises = [ updateLocal(model, response) ];
	} else {
		promises = _.map(response, function(attrs) {
			return updateLocal(new model.model(), attrs);
		});
	}

	return _.reduce(_.map(promises, function(promise) {
		return promise.then(function(response) {

			// Update the timestamp
			var mId = String(response[model.idAttribute]);
			var mTable = config.collection_name;

			if (getInfoForModel({ id: mId, config: {adapter: { collection_name : mTable }}}) != null) { // Also works with an object
				DB.table(config.infoTableName)
				.where({
					m_id: mId,
					m_table: mTable
				})
				.update({
					timestamp: Util.now()
				})
				.run();
			} else {
				DB.table(config.infoTableName)
				.insert({
					m_id: mId,
					m_table: mTable,
					file_name: config.file_name,
					offline: 0,
					timestamp: Util.now()
				})
				.run();
			}
		})
		.catch(function(err) {
			Ti.API.error(LOGNAME + ': could not update the local copy of the model with name ' + config.collection_name + ': ' + err);
		});
	}), Q.when, Q())
	.then(function() {
		// Pass on the original response
		return response;
	});
};

/** Retrieve the local version of a model/collection */
SQLREST.prototype._retrieve = function(opt) {
	var self = this;

	if (self.model instanceof Backbone.Model) {
		return Q.promise(function(resolve, reject) {
			// Check if we can use the local copy
			if (self._isLocalValid()) {
				Local.sync('read', self.model, _.extend({}, opt, {
					success: function(response) {
						resolve(parseResponse(response));
					},
					error: reject
				}));
			} else {
				reject({ message: LOGNAME + ': Model <' + self.config.collection_name + '/' + self.model.id + '> not found or expired.' });
			}
		});
	} else {
		return Q.reject({ message: LOGNAME + ': Cached read not yet supported for collections.' }); // TODO retrieve valid items?
	}
};

/** Completely sync the local version of a model/collection with the remote. */
SQLREST.prototype.fullsync = function(opt) {
	var self = this;

	self._push()
	.then(function() {
		return self._pull();
	})
	.then(function(response) {
		return self._persist(response);
	})
	.then(function(response) {
		if (self.model instanceof Backbone.Model) {
			self.model.set(response);
		} else {
			self.model.reset(response);
		}

		if (_.isFunction(opt.success)) {
			opt.success(response);
		}
	})
	.catch(function(err) {
		if (_.isFunction(opt.error)) {
			opt.error(err);
		}
	});
};

/** Update the local version of a model/collection, and try to send it to the remote. */
SQLREST.prototype.update = SQLREST.prototype.create = function(opt) {
	var self = this;
	var localOpt = _.extend({}, opt, { query: opt.query || opt.localQuery });
	var remoteOpt = _.extend({}, opt, { query: opt.query || opt.remoteQuery });

	self._update(localOpt)
	.then(function(response) {
		addSyncForModel('update', self.model, remoteOpt);

		return self._push()
		.then(function() {
			if (_.isFunction(opt.success)) {
				opt.success(response);
			}
		});
	})
	.catch(opt.error);
};

/** Delete the local version of a model/collection, and try to remove it from the remote. */
SQLREST.prototype.delete = function(opt) {
	var self = this;
	var localOpt = _.extend({}, opt, { query: opt.query || opt.localQuery });
	var remoteOpt = _.extend({}, opt, { query: opt.query || opt.remoteQuery });

	self._update(localOpt)
	.then(function(response) {
		addSyncForModel('delete', self.model, remoteOpt);

		return self._push()
		.then(function() {
			if (_.isFunction(opt.success)) {
				opt.success(response);
			}
		});
	})
	.catch(opt.error);
};

/** Fetch the local version of a model/collection, or pull from the remote and persist it if it doesn't exist. */
SQLREST.prototype.read = function(opt) {
	var self = this;
	var localOpt = _.extend({}, opt, { query: opt.query || opt.localQuery });
	var remoteOpt = _.extend({}, opt, { query: opt.query || opt.remoteQuery });

	// Fix for this adapter and the REST adapter.
	if (self.model instanceof Backbone.Model && opt.id != null) {
		self.model.id = opt.id;
	}

	self._retrieve(localOpt)
	.then(function(response) {
		Ti.API.debug(LOGNAME + ': model ' + self.config.collection_name + '/' + response[self.config.idAttribute || 'id'] + ' retrieved from localAdapter.');

		if (_.isFunction(opt.success)) {
			opt.success(response);
		}
	})
	.catch(function(err) {
		Ti.API.debug(LOGNAME + ':', err);

		self._pull(remoteOpt)
		.then(function(response) {
			// Update the local copy only if we are fetching a model
			// This is to avoid accidentally overwriting models with partial data
			if (self.model instanceof Backbone.Model) {
				self._persist(response)
				.finally(function() {
					if (_.isFunction(opt.success)) {
						opt.success(response);
					}
				});
			} else {
				if (_.isFunction(opt.success)) {
					opt.success(response);
				}
			}
		})
		.catch(opt.error);
	});
};

SQLREST.prototype.setOffline = function(value) {
	var model = this.model;
	var config = this.config;

	if (model == null || config == null) return;

	if (model instanceof Backbone.Model) {
		saveModelInfo(model.id, config, value);
	} else {
		model.each(function(mod) {
			saveModelInfo(mod.id, config, value);
		});
	}
};

SQLREST.prototype.isOffline = function() {
	var row = getInfoForModel(this.model);

	return (row != null) && Boolean(row.offline);
};

SQLREST.prototype.readOffline = function(opt) {
	var self = this;
	opt = opt || {};

	var localOpt = _.extend({}, opt, {
		query: 'SELECT target.* FROM ' + self.config.collection_name + ' target LEFT JOIN ' +
		self.config.infoTableName + ' info ON target.' + (self.config.idAttribute || 'id') +
		' = info.m_id WHERE info.offline = 1',
		success: function(response) {
			Ti.API.debug(LOGNAME + ': offline collection ' + self.config.collection_name + ' retrieved from localAdapter.');

			var resp = [];

			if (response.length) {
				resp = _.map(response, parseResponse);
			} else if (!_.isEmpty(response)) {
				resp = [parseResponse(response)];
			}

			self.model.add(resp);

			if (_.isFunction(opt.success)) {
				opt.success(self.model);
			}
		}
	});

	// Fix for this adapter and the REST adapter.
	if (self.model instanceof Backbone.Model) {
		Ti.API.error(LOGNAME + ': method "readOffline" not supported for models.');

		if (_.isFunction(opt.success)) {
			return opt.success({});
		}
	}

	Local.sync('read', self.model, localOpt);
};

SQLREST.prototype.destroyOffline = function(opt) {
	var self = this;
	opt = opt || {};

	if (self.model instanceof Backbone.Model) {
		if (self.model.id == null) {
			Ti.API.error(LOGNAME + ': couldn\'t destroy model without id.');

			if (_.isFunction(opt.error)) {
				opt.error({});
			}

			return;
		}

		self._destroy()
		.then(opt.success)
		.catch(opt.error);
	} else {
		self.model.readOffline({
			success: function(resp) {
				self._destroy()
				.then(opt.success)
				.catch(opt.error);
			},
			error: opt.error
		});
	}
};

module.exports = SQLREST;