blob: c200b55499384191d78e0afd1bcc3f6205e6f16b [file] [log] [blame]
/*jshint strict: false */
/*global chrome */
var merge = require('./merge');
exports.extend = require('pouchdb-extend');
exports.ajax = require('./deps/ajax');
exports.createBlob = require('./deps/blob');
exports.uuid = require('./deps/uuid');
exports.getArguments = require('argsarray');
var buffer = require('./deps/buffer');
var errors = require('./deps/errors');
var EventEmitter = require('events').EventEmitter;
var collections = require('pouchdb-collections');
exports.Map = collections.Map;
exports.Set = collections.Set;
if (typeof global.Promise === 'function') {
exports.Promise = global.Promise;
} else {
exports.Promise = require('bluebird');
}
var Promise = exports.Promise;
function toObject(array) {
return array.reduce(function (obj, item) {
obj[item] = true;
return obj;
}, {});
}
// List of top level reserved words for doc
var reservedWords = toObject([
'_id',
'_rev',
'_attachments',
'_deleted',
'_revisions',
'_revs_info',
'_conflicts',
'_deleted_conflicts',
'_local_seq',
'_rev_tree',
//replication documents
'_replication_id',
'_replication_state',
'_replication_state_time',
'_replication_state_reason',
'_replication_stats',
// Specific to Couchbase Sync Gateway
'_removed'
]);
// List of reserved words that should end up the document
var dataWords = toObject([
'_attachments',
//replication documents
'_replication_id',
'_replication_state',
'_replication_state_time',
'_replication_state_reason',
'_replication_stats'
]);
exports.lastIndexOf = function (str, char) {
for (var i = str.length - 1; i >= 0; i--) {
if (str.charAt(i) === char) {
return i;
}
}
return -1;
};
exports.clone = function (obj) {
return exports.extend(true, {}, obj);
};
// like underscore/lodash _.pick()
exports.pick = function (obj, arr) {
var res = {};
for (var i = 0, len = arr.length; i < len; i++) {
var prop = arr[i];
res[prop] = obj[prop];
}
return res;
};
exports.inherits = require('inherits');
// Determine id an ID is valid
// - invalid IDs begin with an underescore that does not begin '_design' or
// '_local'
// - any other string value is a valid id
// Returns the specific error object for each case
exports.invalidIdError = function (id) {
var err;
if (!id) {
err = errors.error(errors.MISSING_ID);
} else if (typeof id !== 'string') {
err = errors.error(errors.INVALID_ID);
} else if (/^_/.test(id) && !(/^_(design|local)/).test(id)) {
err = errors.error(errors.RESERVED_ID);
}
if (err) {
throw err;
}
};
function isChromeApp() {
return (typeof chrome !== "undefined" &&
typeof chrome.storage !== "undefined" &&
typeof chrome.storage.local !== "undefined");
}
// Pretty dumb name for a function, just wraps callback calls so we dont
// to if (callback) callback() everywhere
exports.call = exports.getArguments(function (args) {
if (!args.length) {
return;
}
var fun = args.shift();
if (typeof fun === 'function') {
fun.apply(this, args);
}
});
exports.isLocalId = function (id) {
return (/^_local/).test(id);
};
// check if a specific revision of a doc has been deleted
// - metadata: the metadata object from the doc store
// - rev: (optional) the revision to check. defaults to winning revision
exports.isDeleted = function (metadata, rev) {
if (!rev) {
rev = merge.winningRev(metadata);
}
var dashIndex = rev.indexOf('-');
if (dashIndex !== -1) {
rev = rev.substring(dashIndex + 1);
}
var deleted = false;
merge.traverseRevTree(metadata.rev_tree,
function (isLeaf, pos, id, acc, opts) {
if (id === rev) {
deleted = !!opts.deleted;
}
});
return deleted;
};
exports.revExists = function (metadata, rev) {
var found = false;
merge.traverseRevTree(metadata.rev_tree, function (leaf, pos, id) {
if ((pos + '-' + id) === rev) {
found = true;
}
});
return found;
};
exports.filterChange = function filterChange(opts) {
var req = {};
var hasFilter = opts.filter && typeof opts.filter === 'function';
req.query = opts.query_params;
return function filter(change) {
if (opts.filter && hasFilter && !opts.filter.call(this, change.doc, req)) {
return false;
}
if (!opts.include_docs) {
delete change.doc;
} else if (!opts.attachments) {
for (var att in change.doc._attachments) {
if (change.doc._attachments.hasOwnProperty(att)) {
change.doc._attachments[att].stub = true;
}
}
}
return true;
};
};
// Preprocess documents, parse their revisions, assign an id and a
// revision for new writes that are missing them, etc
exports.parseDoc = function (doc, newEdits) {
var nRevNum;
var newRevId;
var revInfo;
var error;
var opts = {status: 'available'};
if (doc._deleted) {
opts.deleted = true;
}
function parseRevisionInfo() {
revInfo = /^(\d+)-(.+)$/.exec(doc._rev);
if (!revInfo) {
error = errors.error(errors.INVALID_REV);
return error;
}
return {
prefix: parseInt(revInfo[1], 10),
id: revInfo[2]
};
}
if (newEdits) {
if (!doc._id) {
doc._id = exports.uuid();
}
newRevId = exports.uuid(32, 16).toLowerCase();
if (doc._rev) {
revInfo = parseRevisionInfo(doc._rev);
if (revInfo.error) {
return revInfo;
}
doc._rev_tree = [{
pos: revInfo.prefix,
ids: [revInfo.id, {status: 'missing'}, [[newRevId, opts, []]]]
}];
nRevNum = revInfo.prefix + 1;
} else {
doc._rev_tree = [{
pos: 1,
ids : [newRevId, opts, []]
}];
nRevNum = 1;
}
} else {
if (doc._revisions) {
doc._rev_tree = [{
pos: doc._revisions.start - doc._revisions.ids.length + 1,
ids: doc._revisions.ids.reduce(function (acc, x) {
if (acc === null) {
return [x, opts, []];
} else {
return [x, {status: 'missing'}, [acc]];
}
}, null)
}];
nRevNum = doc._revisions.start;
newRevId = doc._revisions.ids[0];
}
if (!doc._rev_tree) {
revInfo = parseRevisionInfo(doc._rev);
if (revInfo.error) {
return revInfo;
}
nRevNum = revInfo.prefix;
newRevId = revInfo.id;
doc._rev_tree = [{
pos: nRevNum,
ids: [newRevId, opts, []]
}];
}
}
exports.invalidIdError(doc._id);
doc._rev = [nRevNum, newRevId].join('-');
var result = {metadata : {}, data : {}};
for (var key in doc) {
if (doc.hasOwnProperty(key)) {
var specialKey = key[0] === '_';
if (specialKey && !reservedWords[key]) {
error = errors.error(errors.DOC_VALIDATION, key);
error.message = errors.DOC_VALIDATION.message + ': ' + key;
throw error;
} else if (specialKey && !dataWords[key]) {
result.metadata[key.slice(1)] = doc[key];
} else {
result.data[key] = doc[key];
}
}
}
return result;
};
exports.isCordova = function () {
return (typeof cordova !== "undefined" ||
typeof PhoneGap !== "undefined" ||
typeof phonegap !== "undefined");
};
exports.hasLocalStorage = function () {
if (isChromeApp()) {
return false;
}
try {
return localStorage;
} catch (e) {
return false;
}
};
exports.Changes = Changes;
exports.inherits(Changes, EventEmitter);
function Changes() {
if (!(this instanceof Changes)) {
return new Changes();
}
var self = this;
EventEmitter.call(this);
this.isChrome = isChromeApp();
this.listeners = {};
this.hasLocal = false;
if (!this.isChrome) {
this.hasLocal = exports.hasLocalStorage();
}
if (this.isChrome) {
chrome.storage.onChanged.addListener(function (e) {
// make sure it's event addressed to us
if (e.db_name != null) {
//object only has oldValue, newValue members
self.emit(e.dbName.newValue);
}
});
} else if (this.hasLocal) {
if (typeof addEventListener !== 'undefined') {
addEventListener("storage", function (e) {
self.emit(e.key);
});
} else { // old IE
window.attachEvent("storage", function (e) {
self.emit(e.key);
});
}
}
}
Changes.prototype.addListener = function (dbName, id, db, opts) {
if (this.listeners[id]) {
return;
}
var self = this;
var inprogress = false;
function eventFunction() {
if (!self.listeners[id]) {
return;
}
if (inprogress) {
inprogress = 'waiting';
return;
}
inprogress = true;
db.changes({
include_docs: opts.include_docs,
attachments: opts.attachments,
conflicts: opts.conflicts,
continuous: false,
descending: false,
filter: opts.filter,
doc_ids: opts.doc_ids,
view: opts.view,
since: opts.since,
query_params: opts.query_params
}).on('change', function (c) {
if (c.seq > opts.since && !opts.cancelled) {
opts.since = c.seq;
exports.call(opts.onChange, c);
}
}).on('complete', function () {
if (inprogress === 'waiting') {
process.nextTick(function () {
self.notify(dbName);
});
}
inprogress = false;
}).on('error', function () {
inprogress = false;
});
}
this.listeners[id] = eventFunction;
this.on(dbName, eventFunction);
};
Changes.prototype.removeListener = function (dbName, id) {
if (!(id in this.listeners)) {
return;
}
EventEmitter.prototype.removeListener.call(this, dbName,
this.listeners[id]);
};
Changes.prototype.notifyLocalWindows = function (dbName) {
//do a useless change on a storage thing
//in order to get other windows's listeners to activate
if (this.isChrome) {
chrome.storage.local.set({dbName: dbName});
} else if (this.hasLocal) {
localStorage[dbName] = (localStorage[dbName] === "a") ? "b" : "a";
}
};
Changes.prototype.notify = function (dbName) {
this.emit(dbName);
this.notifyLocalWindows(dbName);
};
if (typeof window === 'undefined' || typeof window.atob !== 'function') {
exports.atob = function (str) {
var base64 = new buffer(str, 'base64');
// Node.js will just skip the characters it can't encode instead of
// throwing and exception
if (base64.toString('base64') !== str) {
throw ("Cannot base64 encode full string");
}
return base64.toString('binary');
};
} else {
exports.atob = function (str) {
return atob(str);
};
}
if (typeof window === 'undefined' || typeof window.btoa !== 'function') {
exports.btoa = function (str) {
return new buffer(str, 'binary').toString('base64');
};
} else {
exports.btoa = function (str) {
return btoa(str);
};
}
// From http://stackoverflow.com/questions/14967647/ (continues on next line)
// encode-decode-image-with-base64-breaks-image (2013-04-21)
exports.fixBinary = function (bin) {
if (!process.browser) {
// don't need to do this in Node
return bin;
}
var length = bin.length;
var buf = new ArrayBuffer(length);
var arr = new Uint8Array(buf);
for (var i = 0; i < length; i++) {
arr[i] = bin.charCodeAt(i);
}
return buf;
};
// shim for browsers that don't support it
exports.readAsBinaryString = function (blob, callback) {
var reader = new FileReader();
var hasBinaryString = typeof reader.readAsBinaryString === 'function';
reader.onloadend = function (e) {
var result = e.target.result || '';
if (hasBinaryString) {
return callback(result);
}
callback(exports.arrayBufferToBinaryString(result));
};
if (hasBinaryString) {
reader.readAsBinaryString(blob);
} else {
reader.readAsArrayBuffer(blob);
}
};
// simplified API. universal browser support is assumed
exports.readAsArrayBuffer = function (blob, callback) {
var reader = new FileReader();
reader.onloadend = function (e) {
var result = e.target.result || new ArrayBuffer(0);
callback(result);
};
reader.readAsArrayBuffer(blob);
};
exports.once = function (fun) {
var called = false;
return exports.getArguments(function (args) {
if (called) {
throw new Error('once called more than once');
} else {
called = true;
fun.apply(this, args);
}
});
};
exports.toPromise = function (func) {
//create the function we will be returning
return exports.getArguments(function (args) {
var self = this;
var tempCB =
(typeof args[args.length - 1] === 'function') ? args.pop() : false;
// if the last argument is a function, assume its a callback
var usedCB;
if (tempCB) {
// if it was a callback, create a new callback which calls it,
// but do so async so we don't trap any errors
usedCB = function (err, resp) {
process.nextTick(function () {
tempCB(err, resp);
});
};
}
var promise = new Promise(function (fulfill, reject) {
var resp;
try {
var callback = exports.once(function (err, mesg) {
if (err) {
reject(err);
} else {
fulfill(mesg);
}
});
// create a callback for this invocation
// apply the function in the orig context
args.push(callback);
resp = func.apply(self, args);
if (resp && typeof resp.then === 'function') {
fulfill(resp);
}
} catch (e) {
reject(e);
}
});
// if there is a callback, call it back
if (usedCB) {
promise.then(function (result) {
usedCB(null, result);
}, usedCB);
}
promise.cancel = function () {
return this;
};
return promise;
});
};
exports.adapterFun = function (name, callback) {
var log = require('debug')('pouchdb:api');
function logApiCall(self, name, args) {
if (!log.enabled) {
return;
}
var logArgs = [self._db_name, name];
for (var i = 0; i < args.length - 1; i++) {
logArgs.push(args[i]);
}
log.apply(null, logArgs);
// override the callback itself to log the response
var origCallback = args[args.length - 1];
args[args.length - 1] = function (err, res) {
var responseArgs = [self._db_name, name];
responseArgs = responseArgs.concat(
err ? ['error', err] : ['success', res]
);
log.apply(null, responseArgs);
origCallback(err, res);
};
}
return exports.toPromise(exports.getArguments(function (args) {
if (this._closed) {
return Promise.reject(new Error('database is closed'));
}
var self = this;
logApiCall(self, name, args);
if (!this.taskqueue.isReady) {
return new exports.Promise(function (fulfill, reject) {
self.taskqueue.addTask(function (failed) {
if (failed) {
reject(failed);
} else {
fulfill(self[name].apply(self, args));
}
});
});
}
return callback.apply(this, args);
}));
};
//Can't find original post, but this is close
//http://stackoverflow.com/questions/6965107/ (continues on next line)
//converting-between-strings-and-arraybuffers
exports.arrayBufferToBinaryString = function (buffer) {
var binary = "";
var bytes = new Uint8Array(buffer);
var length = bytes.byteLength;
for (var i = 0; i < length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return binary;
};
exports.cancellableFun = function (fun, self, opts) {
opts = opts ? exports.clone(true, {}, opts) : {};
var emitter = new EventEmitter();
var oldComplete = opts.complete || function () { };
var complete = opts.complete = exports.once(function (err, resp) {
if (err) {
oldComplete(err);
} else {
emitter.emit('end', resp);
oldComplete(null, resp);
}
emitter.removeAllListeners();
});
var oldOnChange = opts.onChange || function () {};
var lastChange = 0;
self.on('destroyed', function () {
emitter.removeAllListeners();
});
opts.onChange = function (change) {
oldOnChange(change);
if (change.seq <= lastChange) {
return;
}
lastChange = change.seq;
emitter.emit('change', change);
if (change.deleted) {
emitter.emit('delete', change);
} else if (change.changes.length === 1 &&
change.changes[0].rev.slice(0, 1) === '1-') {
emitter.emit('create', change);
} else {
emitter.emit('update', change);
}
};
var promise = new Promise(function (fulfill, reject) {
opts.complete = function (err, res) {
if (err) {
reject(err);
} else {
fulfill(res);
}
};
});
promise.then(function (result) {
complete(null, result);
}, complete);
// this needs to be overwridden by caller, dont fire complete until
// the task is ready
promise.cancel = function () {
promise.isCancelled = true;
if (self.taskqueue.isReady) {
opts.complete(null, {status: 'cancelled'});
}
};
if (!self.taskqueue.isReady) {
self.taskqueue.addTask(function () {
if (promise.isCancelled) {
opts.complete(null, {status: 'cancelled'});
} else {
fun(self, opts, promise);
}
});
} else {
fun(self, opts, promise);
}
promise.on = emitter.on.bind(emitter);
promise.once = emitter.once.bind(emitter);
promise.addListener = emitter.addListener.bind(emitter);
promise.removeListener = emitter.removeListener.bind(emitter);
promise.removeAllListeners = emitter.removeAllListeners.bind(emitter);
promise.setMaxListeners = emitter.setMaxListeners.bind(emitter);
promise.listeners = emitter.listeners.bind(emitter);
promise.emit = emitter.emit.bind(emitter);
return promise;
};
exports.MD5 = exports.toPromise(require('./deps/md5'));
// designed to give info to browser users, who are disturbed
// when they see 404s in the console
exports.explain404 = function (str) {
if (process.browser && 'console' in global && 'info' in console) {
console.info('The above 404 is totally normal. ' + str);
}
};
exports.info = function (str) {
if (typeof console !== 'undefined' && 'info' in console) {
console.info(str);
}
};
exports.parseUri = require('./deps/parse-uri');
exports.compare = function (left, right) {
return left < right ? -1 : left > right ? 1 : 0;
};
exports.updateDoc = function updateDoc(prev, docInfo, results,
i, cb, writeDoc, newEdits) {
if (exports.revExists(prev, docInfo.metadata.rev)) {
results[i] = docInfo;
return cb();
}
var previouslyDeleted = exports.isDeleted(prev);
var deleted = exports.isDeleted(docInfo.metadata);
var isRoot = /^1-/.test(docInfo.metadata.rev);
if (previouslyDeleted && !deleted && newEdits && isRoot) {
var newDoc = docInfo.data;
newDoc._rev = merge.winningRev(prev);
newDoc._id = docInfo.metadata.id;
docInfo = exports.parseDoc(newDoc, newEdits);
}
var merged = merge.merge(prev.rev_tree, docInfo.metadata.rev_tree[0], 1000);
var inConflict = newEdits && (((previouslyDeleted && deleted) ||
(!previouslyDeleted && merged.conflicts !== 'new_leaf') ||
(previouslyDeleted && !deleted && merged.conflicts === 'new_branch')));
if (inConflict) {
var err = errors.error(errors.REV_CONFLICT);
results[i] = err;
return cb();
}
var newRev = docInfo.metadata.rev;
docInfo.metadata.rev_tree = merged.tree;
if (prev.rev_map) {
docInfo.metadata.rev_map = prev.rev_map; // used by leveldb
}
// recalculate
var winningRev = merge.winningRev(docInfo.metadata);
deleted = exports.isDeleted(docInfo.metadata, winningRev);
var delta = 0;
if (newEdits || winningRev === newRev) {
// if newEdits==false and we're pushing existing revisions,
// then the only thing that matters is whether this revision
// is the winning one, and thus replaces an old one
delta = (previouslyDeleted === deleted) ? 0 :
previouslyDeleted < deleted ? -1 : 1;
}
writeDoc(docInfo, winningRev, deleted, cb, true, delta, i);
};
exports.processDocs = function processDocs(docInfos, api, fetchedDocs,
tx, results, writeDoc, opts,
overallCallback) {
if (!docInfos.length) {
return;
}
function insertDoc(docInfo, resultsIdx, callback) {
// Cant insert new deleted documents
var winningRev = merge.winningRev(docInfo.metadata);
var deleted = exports.isDeleted(docInfo.metadata, winningRev);
if ('was_delete' in opts && deleted) {
results[resultsIdx] = errors.error(errors.MISSING_DOC, 'deleted');
return callback();
}
var delta = deleted ? 0 : 1;
writeDoc(docInfo, winningRev, deleted, callback, false, delta, resultsIdx);
}
var newEdits = opts.new_edits;
var idsToDocs = new exports.Map();
var docsDone = 0;
var docsToDo = docInfos.length;
function checkAllDocsDone() {
if (++docsDone === docsToDo && overallCallback) {
overallCallback();
}
}
docInfos.forEach(function (currentDoc, resultsIdx) {
if (currentDoc._id && exports.isLocalId(currentDoc._id)) {
api[currentDoc._deleted ? '_removeLocal' : '_putLocal'](
currentDoc, {ctx: tx}, function (err) {
if (err) {
results[resultsIdx] = err;
} else {
results[resultsIdx] = {ok: true};
}
checkAllDocsDone();
});
return;
}
var id = currentDoc.metadata.id;
if (idsToDocs.has(id)) {
docsToDo--; // duplicate
idsToDocs.get(id).push([currentDoc, resultsIdx]);
} else {
idsToDocs.set(id, [[currentDoc, resultsIdx]]);
}
});
// in the case of new_edits, the user can provide multiple docs
// with the same id. these need to be processed sequentially
idsToDocs.forEach(function (docs, id) {
var numDone = 0;
function docWritten() {
if (++numDone < docs.length) {
nextDoc();
} else {
checkAllDocsDone();
}
}
function nextDoc() {
var value = docs[numDone];
var currentDoc = value[0];
var resultsIdx = value[1];
if (fetchedDocs.has(id)) {
exports.updateDoc(fetchedDocs.get(id), currentDoc, results,
resultsIdx, docWritten, writeDoc, newEdits);
} else {
insertDoc(currentDoc, resultsIdx, docWritten);
}
}
nextDoc();
});
};
exports.preprocessAttachments = function preprocessAttachments(
docInfos, blobType, callback) {
if (!docInfos.length) {
return callback();
}
var docv = 0;
function parseBase64(data) {
try {
return exports.atob(data);
} catch (e) {
var err = errors.error(errors.BAD_ARG,
'Attachments need to be base64 encoded');
return {error: err};
}
}
function preprocessAttachment(att, callback) {
if (att.stub) {
return callback();
}
if (typeof att.data === 'string') {
// input is a base64 string
var asBinary = parseBase64(att.data);
if (asBinary.error) {
return callback(asBinary.error);
}
att.length = asBinary.length;
if (blobType === 'blob') {
att.data = exports.createBlob([exports.fixBinary(asBinary)],
{type: att.content_type});
} else if (blobType === 'base64') {
att.data = exports.btoa(asBinary);
} else { // binary
att.data = asBinary;
}
exports.MD5(asBinary).then(function (result) {
att.digest = 'md5-' + result;
callback();
});
} else { // input is a blob
exports.readAsArrayBuffer(att.data, function (buff) {
if (blobType === 'binary') {
att.data = exports.arrayBufferToBinaryString(buff);
} else if (blobType === 'base64') {
att.data = exports.btoa(exports.arrayBufferToBinaryString(buff));
}
exports.MD5(buff).then(function (result) {
att.digest = 'md5-' + result;
att.length = buff.byteLength;
callback();
});
});
}
}
var overallErr;
docInfos.forEach(function (docInfo) {
var attachments = docInfo.data && docInfo.data._attachments ?
Object.keys(docInfo.data._attachments) : [];
var recv = 0;
if (!attachments.length) {
return done();
}
function processedAttachment(err) {
overallErr = err;
recv++;
if (recv === attachments.length) {
done();
}
}
for (var key in docInfo.data._attachments) {
if (docInfo.data._attachments.hasOwnProperty(key)) {
preprocessAttachment(docInfo.data._attachments[key],
processedAttachment);
}
}
});
function done() {
docv++;
if (docInfos.length === docv) {
if (overallErr) {
callback(overallErr);
} else {
callback();
}
}
}
};
// compact a tree by marking its non-leafs as missing,
// and return a list of revs to delete
exports.compactTree = function compactTree(metadata) {
var revs = [];
merge.traverseRevTree(metadata.rev_tree, function (isLeaf, pos,
revHash, ctx, opts) {
if (opts.status === 'available' && !isLeaf) {
revs.push(pos + '-' + revHash);
opts.status = 'missing';
}
});
return revs;
};
var vuvuzela = require('vuvuzela');
exports.safeJsonParse = function safeJsonParse(str) {
try {
return JSON.parse(str);
} catch (e) {
return vuvuzela.parse(str);
}
};
exports.safeJsonStringify = function safeJsonStringify(json) {
try {
return JSON.stringify(json);
} catch (e) {
return vuvuzela.stringify(json);
}
};