josneville josneville - 28 days ago 23
Javascript Question

How to version control an object?

To explain, look at the object below as it is being changed:

obj = {'a': 1, 'b': 2} // Version 1
obj['a'] = 2 // Version 2
obj['c'] = 3 // Version 3


I want to be able to get any of these versions of the object, for e.g. get
obj
as of version 2. I don't want to store copies of the entire object every single time I want to update a single key.

How can I achieve this functionality?

The actual object I'm trying to do this with has about 500,000 keys. That's why I don't want to store entire copies it with every update. My preferred language that this theoretical solution should be coded in are
python
or
javascript
but I'll take anything.

Answer

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; }