blob: d54a3fcc6223e08f002d2d42acea0f36ac00c73a [file] [log] [blame]
'use strict';
var extend = require('pouchdb-extend');
// for a better overview of what this is doing, read:
// https://github.com/apache/couchdb/blob/master/src/couchdb/couch_key_tree.erl
//
// But for a quick intro, CouchDB uses a revision tree to store a documents
// history, A -> B -> C, when a document has conflicts, that is a branch in the
// tree, A -> (B1 | B2 -> C), We store these as a nested array in the format
//
// KeyTree = [Path ... ]
// Path = {pos: position_from_root, ids: Tree}
// Tree = [Key, Opts, [Tree, ...]], in particular single node: [Key, []]
// classic binary search
function binarySearch(arr, item, comparator) {
var low = 0;
var high = arr.length;
var mid;
while (low < high) {
mid = (low + high) >>> 1;
if (comparator(arr[mid], item) < 0) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
// assuming the arr is sorted, insert the item in the proper place
function insertSorted(arr, item, comparator) {
var idx = binarySearch(arr, item, comparator);
arr.splice(idx, 0, item);
}
// Turn a path as a flat array into a tree with a single branch
function pathToTree(path) {
var doc = path.shift();
var root = [doc.id, doc.opts, []];
var leaf = root;
var nleaf;
while (path.length) {
doc = path.shift();
nleaf = [doc.id, doc.opts, []];
leaf[2].push(nleaf);
leaf = nleaf;
}
return root;
}
// compare the IDs of two trees
function compareTree(a, b) {
return a[0] < b[0] ? -1 : 1;
}
// Merge two trees together
// The roots of tree1 and tree2 must be the same revision
function mergeTree(in_tree1, in_tree2) {
var queue = [{tree1: in_tree1, tree2: in_tree2}];
var conflicts = false;
while (queue.length > 0) {
var item = queue.pop();
var tree1 = item.tree1;
var tree2 = item.tree2;
if (tree1[1].status || tree2[1].status) {
tree1[1].status =
(tree1[1].status === 'available' ||
tree2[1].status === 'available') ? 'available' : 'missing';
}
for (var i = 0; i < tree2[2].length; i++) {
if (!tree1[2][0]) {
conflicts = 'new_leaf';
tree1[2][0] = tree2[2][i];
continue;
}
var merged = false;
for (var j = 0; j < tree1[2].length; j++) {
if (tree1[2][j][0] === tree2[2][i][0]) {
queue.push({tree1: tree1[2][j], tree2: tree2[2][i]});
merged = true;
}
}
if (!merged) {
conflicts = 'new_branch';
insertSorted(tree1[2], tree2[2][i], compareTree);
}
}
}
return {conflicts: conflicts, tree: in_tree1};
}
function doMerge(tree, path, dontExpand) {
var restree = [];
var conflicts = false;
var merged = false;
var res;
if (!tree.length) {
return {tree: [path], conflicts: 'new_leaf'};
}
tree.forEach(function (branch) {
if (branch.pos === path.pos && branch.ids[0] === path.ids[0]) {
// Paths start at the same position and have the same root, so they need
// merged
res = mergeTree(branch.ids, path.ids);
restree.push({pos: branch.pos, ids: res.tree});
conflicts = conflicts || res.conflicts;
merged = true;
} else if (dontExpand !== true) {
// The paths start at a different position, take the earliest path and
// traverse up until it as at the same point from root as the path we
// want to merge. If the keys match we return the longer path with the
// other merged After stemming we dont want to expand the trees
var t1 = branch.pos < path.pos ? branch : path;
var t2 = branch.pos < path.pos ? path : branch;
var diff = t2.pos - t1.pos;
var candidateParents = [];
var trees = [];
trees.push({ids: t1.ids, diff: diff, parent: null, parentIdx: null});
while (trees.length > 0) {
var item = trees.pop();
if (item.diff === 0) {
if (item.ids[0] === t2.ids[0]) {
candidateParents.push(item);
}
continue;
}
if (!item.ids) {
continue;
}
/*jshint loopfunc:true */
item.ids[2].forEach(function (el, idx) {
trees.push(
{ids: el, diff: item.diff - 1, parent: item.ids, parentIdx: idx});
});
}
var el = candidateParents[0];
if (!el) {
restree.push(branch);
} else {
res = mergeTree(el.ids, t2.ids);
el.parent[2][el.parentIdx] = res.tree;
restree.push({pos: t1.pos, ids: t1.ids});
conflicts = conflicts || res.conflicts;
merged = true;
}
} else {
restree.push(branch);
}
});
// We didnt find
if (!merged) {
restree.push(path);
}
restree.sort(function (a, b) {
return a.pos - b.pos;
});
return {
tree: restree,
conflicts: conflicts || 'internal_node'
};
}
// To ensure we dont grow the revision tree infinitely, we stem old revisions
function stem(tree, depth) {
// First we break out the tree into a complete list of root to leaf paths,
// we cut off the start of the path and generate a new set of flat trees
var stemmedPaths = PouchMerge.rootToLeaf(tree).map(function (path) {
var stemmed = path.ids.slice(-depth);
return {
pos: path.pos + (path.ids.length - stemmed.length),
ids: pathToTree(stemmed)
};
});
// Then we remerge all those flat trees together, ensuring that we dont
// connect trees that would go beyond the depth limit
return stemmedPaths.reduce(function (prev, current) {
return doMerge(prev, current, true).tree;
}, [stemmedPaths.shift()]);
}
var PouchMerge = {};
PouchMerge.merge = function (tree, path, depth) {
// Ugh, nicer way to not modify arguments in place?
tree = extend(true, [], tree);
path = extend(true, {}, path);
var newTree = doMerge(tree, path);
return {
tree: stem(newTree.tree, depth),
conflicts: newTree.conflicts
};
};
// We fetch all leafs of the revision tree, and sort them based on tree length
// and whether they were deleted, undeleted documents with the longest revision
// tree (most edits) win
// The final sort algorithm is slightly documented in a sidebar here:
// http://guide.couchdb.org/draft/conflicts.html
PouchMerge.winningRev = function (metadata) {
var leafs = [];
PouchMerge.traverseRevTree(metadata.rev_tree,
function (isLeaf, pos, id, something, opts) {
if (isLeaf) {
leafs.push({pos: pos, id: id, deleted: !!opts.deleted});
}
});
leafs.sort(function (a, b) {
if (a.deleted !== b.deleted) {
return a.deleted > b.deleted ? 1 : -1;
}
if (a.pos !== b.pos) {
return b.pos - a.pos;
}
return a.id < b.id ? 1 : -1;
});
return leafs[0].pos + '-' + leafs[0].id;
};
// Pretty much all below can be combined into a higher order function to
// traverse revisions
// The return value from the callback will be passed as context to all
// children of that node
PouchMerge.traverseRevTree = function (revs, callback) {
var toVisit = revs.slice();
var node;
while ((node = toVisit.pop())) {
var pos = node.pos;
var tree = node.ids;
var branches = tree[2];
var newCtx =
callback(branches.length === 0, pos, tree[0], node.ctx, tree[1]);
for (var i = 0, len = branches.length; i < len; i++) {
toVisit.push({pos: pos + 1, ids: branches[i], ctx: newCtx});
}
}
};
PouchMerge.collectLeaves = function (revs) {
var leaves = [];
PouchMerge.traverseRevTree(revs, function (isLeaf, pos, id, acc, opts) {
if (isLeaf) {
leaves.push({rev: pos + "-" + id, pos: pos, opts: opts});
}
});
leaves.sort(function (a, b) {
return b.pos - a.pos;
});
leaves.forEach(function (leaf) { delete leaf.pos; });
return leaves;
};
// returns revs of all conflicts that is leaves such that
// 1. are not deleted and
// 2. are different than winning revision
PouchMerge.collectConflicts = function (metadata) {
var win = PouchMerge.winningRev(metadata);
var leaves = PouchMerge.collectLeaves(metadata.rev_tree);
var conflicts = [];
leaves.forEach(function (leaf) {
if (leaf.rev !== win && !leaf.opts.deleted) {
conflicts.push(leaf.rev);
}
});
return conflicts;
};
PouchMerge.rootToLeaf = function (tree) {
var paths = [];
PouchMerge.traverseRevTree(tree, function (isLeaf, pos, id, history, opts) {
history = history ? history.slice(0) : [];
history.push({id: id, opts: opts});
if (isLeaf) {
var rootPos = pos + 1 - history.length;
paths.unshift({pos: rootPos, ids: history});
}
return history;
});
return paths;
};
module.exports = PouchMerge;