How to version control an object?
You could use ES6 proxies for that. These would trap any read/write operation on your object and log each change in a change log that can be used for rolling changes back and forward.
Below is a basic implementation, which might need some more features if you intend to apply other than basic update operations on your object. It allows to get the current version number and move the object back (or forward) to a specific version. Whenever you make a change to the object, it is first moved to its latest version.
This snippet shows some operations, like changing a string property, adding to an array, and shifting it, while moving back and forward to other versions.
Edit: It now also has capability to get the change log as an object, and apply that change log to the initial object. This way you can save the JSON of both the initial object and the change log, and replay the changes to get the final object.
function VersionControlled(obj, changeLog = []) {
var targets = [], version = 0, savedLength,
hash = new Map([[obj, []]]),
handler = {
get: function(target, property) {
var x = target[property];
if (Object(x) !== x) return x;
hash.set(x, hash.get(target).concat(property));
return new Proxy(x, handler);
},
set: update,
deleteProperty: update
};
function gotoVersion(newVersion) {
newVersion = Math.max(0, Math.min(changeLog.length, newVersion));
var chg, target, path, property,
val = newVersion > version ? 'newValue' : 'oldValue';
while (version !== newVersion) {
if (version > newVersion) version--;
chg = changeLog[version];
path = chg.path.slice();
property = path.pop();
target = targets[version] ||
(targets[version] = path.reduce ( (o, p) => o[p], obj ));
if (chg.hasOwnProperty(val)) {
target[property] = chg[val];
} else {
delete target[property];
}
if (version < newVersion) version++;
}
return true;
}
function gotoLastVersion() {
return gotoVersion(changeLog.length);
}
function update(target, property, value) {
gotoLastVersion(); // only last version can be modified
var change = {path: hash.get(target).concat([property])};
if (arguments.length > 2) change.newValue = value;
// Some care concerning the length property of arrays:
if (Array.isArray(target) && +property >= target.length) {
savedLength = target.length;
}
if (property in target) {
if (property === 'length' && savedLength !== undefined) {
change.oldValue = savedLength;
savedLength = undefined;
} else {
change.oldValue = target[property];
}
}
changeLog.push(change);
targets.push(target);
return gotoLastVersion();
}
this.data = new Proxy(obj, handler);
this.getVersion = _ => version;
this.gotoVersion = gotoVersion;
this.gotoLastVersion = gotoLastVersion;
this.getChangeLog = _ => changeLog;
// apply change log
gotoLastVersion();
}
// sample data
var obj = { list: [1, { p: 'hello' }, 3] };
// Get versioning object for it
var vc = new VersionControlled(obj);
obj = vc.data; // we don't need the original anymore, this one looks the same
// Demo of actions:
console.log(`v${vc.getVersion()} ${JSON.stringify(obj)}. Change text:`);
obj.list[1].p = 'bye';
console.log(`v${vc.getVersion()} ${JSON.stringify(obj)}. Bookmark & add property:`);
var bookmark = vc.getVersion();
obj.list[1].q = ['added'];
console.log(`v${vc.getVersion()} ${JSON.stringify(obj)}. Push on list, then shift:`);
obj.list.push(4); // changes both length and index '4' property => 2 version increments
obj.list.shift(); // several changes and a deletion
console.log(`v${vc.getVersion()} ${JSON.stringify(obj)}. Go to bookmark:`);
vc.gotoVersion(bookmark);
console.log(`v${vc.getVersion()} ${JSON.stringify(obj)}. Go to last version:`);
vc.gotoLastVersion();
console.log(`v${vc.getVersion()} ${JSON.stringify(obj)}. Get change log:`);
var changeLog = vc.getChangeLog();
for (var chg of changeLog) {
console.log(JSON.stringify(chg));
}
console.log('Restart from scratch, and apply the change log:');
obj = { list: [1, { p: 'hello' }, 3] };
vc = new VersionControlled(obj, changeLog);
obj = vc.data;
console.log(`v${vc.getVersion()} ${JSON.stringify(obj)}`);
.as-console-wrapper { max-height: 100% !important; top: 0; }