Friday, March 6, 2015

Safely use IndexedDB in iOS 8


I'm in a situation where users of my app ScoreGeek are filling up the 5MB limit imposed on LocalStorage and I need to switch to IndexedDB.

Upon making the switch I found that iOS and IndexedDB is seriously buggy when it comes to non-unique keys (it deletes values across separate tables if they share the same key...  jaw dropping bad bug).

I have a workaround that does essentially this:

1)  Whenever you set an object from IDB you pass the existing object ID and the table name

2)  The table name, original ID and variable type are used to create a unique string

3)  The unique string is used as the new key.  The original key is appended to the object as "origKey".

4)  When you get an object you pass the table name and key again.  That is used to find the object.  Then the "origKey" value is set back to the keypath and the object is returned.

Using this method no two tables will ever have the same key, so the iOS bug is avoided.

If you must sort numerically by ID then you will want to create an Index for origKey and sort using that. You can also create a KeyRange for origKey to find specific values or ranges.

Hopefully this will help other people who are pulling their hair out over this.  I have tried to comment out any lines that are specific to my code (like the Toast.toast stuff) but I haven't run this code standalone.

Here is the code:


var idbConvertId = function(id, table) {
    var s = "?";
    var sKeyPath;
    if (typeof id === "string") {
        s = "s";
        sKeyPath = id;
    } else if (typeof id === "number") {
        s = "n";
        sKeyPath = id.toString();
    }
    sKeyPath = sKeyPath.replace(/\//g, "[[[fs]]]");
    id = table + "/" + s + "/" + sKeyPath;
    return id;
};

idbRevertId = function(obj, keyPath) {
    obj[keyPath] = obj.origKey;
    delete obj.origKey;
    return obj;
};

var idbSetObj = function(obj, table, callback) {
    var value = JSON.parse(JSON.stringify(obj));
    var db = myIndexedDB.db;
    if (db) {
        try {
            var trans = db.transaction([table], "readwrite");
            var store = trans.objectStore(table);
            var keyPath = store.keyPath;
            value.origKey = value[keyPath];
            value[keyPath] = idbConvertId(value[keyPath], table);
            var request = store.put(value);
            trans.oncomplete = function(e) {
                callback(true);
            };
            trans.onabort = function(event) {
                var error = event.target.error; // DOMError
                if (error.name == 'QuotaExceededError') {
                    // Fallback code comes here
                    Toast.toast("Storage quota exceeded, some data was not saved");
                } else {
                    Toast.toast("Transaction " + err.name + ": " + err.message);
                    //console.log(err);
                }
            };
            request.onerror = function(e) {
                Toast.toast("Request " + err.name + ": " + err.message);
                callback(false);
            };
        } catch (err) {
            Toast.toastMiniLong("Store " + err.name + ": " + err.message);
            CloudAll.abort = true;
            callback(false);
        }
    } else {
        callback(false);
    }
};

var idbGetObj = function(id, table, callback) {
    var db = myIndexedDB.db;
    if (db) {
        var trans = db.transaction([table], "readonly");
        var store = trans.objectStore(table);
        var keyPath = store.keyPath;
        var keyRange;
        id = idbConvertId(id, table);
        keyRange = IDBKeyRange.only(id);
        var cursorRequest = store.openCursor(keyRange);
        var ret;
        cursorRequest.onsuccess = function(e) {
            var result = e.target.result;
            if (result) {
                ret = result.value;
                ret = idbRevertId(ret, keyPath);
                callback(ret);
            } else {
                callback(null);
            }
        };
        cursorRequest.onerror = function(e) {
            callback(false);
        };
    } else {
        callback(false);
    }
};

var idbFindObjs = function(tableName, keyRangeObj, indexName, maxRecords, sortOrder, callback) {
    var count = 0;
    var max;
    if (maxRecords === 0) {
        max = 100000000;
    } else {
        max = maxRecords;
    }
    var objs = [];
    var db = myIndexedDB.db;
    var ret;
    if (db) {
        var trans = db.transaction([tableName], "readonly");
        var store = trans.objectStore(tableName);
        var keyPath = store.keyPath;
        var cursorRequest;
        if (!indexName) {
            cursorRequest = store.openCursor(keyRangeObj, sortOrder);
        } else {
            var myIndex = store.index(indexName);
            cursorRequest = myIndex.openCursor(keyRangeObj, sortOrder);
        }
        cursorRequest.onsuccess = function(e) {
            var result = e.target.result;
            if (result) {
                ret = result.value;
                ret = idbRevertId(ret, keyPath);
                objs.push(ret);
                count++;
                if (count < max) {
                    result.continue();
                } else {
                    callback(objs);
                }
            } else {
                callback(objs);
            }
        };
        cursorRequest.onerror = function(e) {
            callback(Globals.empty);
        };
    } else {
        callback(Globals.empty);
    }
};

var idbFindObjKeys = function(tableName, keyRangeObj, indexName, maxRecords, sortOrder, callback) {
    var count = 0;
    var max;
    if (maxRecords === 0) {
        max = 100000000;
    } else {
        max = maxRecords;
    }
    var objs = [];
    var db = myIndexedDB.db;
    if (db) {
        var trans = db.transaction([tableName], "readonly");
        var store = trans.objectStore(tableName);
        var cursorRequest;
        if (!indexName) {
            cursorRequest = store.openCursor(keyRangeObj, sortOrder);
        } else {
            var myIndex = store.index(indexName);
            cursorRequest = myIndex.openCursor(keyRangeObj, sortOrder);
        }
        cursorRequest.onsuccess = function(e) {
            var result = e.target.result;
            if (result) {
                objs.push(result.primaryKey);
                count++;
                if (count < max) {
                    result.continue();
                } else {
                    callback(objs);
                }
            } else {
                callback(objs);
            }
        };
        cursorRequest.onerror = function(e) {
            callback(Globals.empty);
        };
    } else {
        callback(Globals.empty);
    }
};

var idbDelObj = function(id, table, callback) {
    var db = myIndexedDB.db;
    var trans = db.transaction([table], "readwrite");
    var store = trans.objectStore(table);
    var keyPath = store.keyPath;
    var new_id = idbConvertId(id, table);
    var request = store.delete(new_id);
    trans.oncomplete = function(e) {
        callback(true);
    };
    request.onerror = function(e) {
        callback(false);
    };
};

var idbDelObjs = function(tableName, keyRangeObj, indexName, maxRecords, callback) {
    var a;
    var id;
    idbFindObjKeys(tableName, keyRangeObj, indexName, maxRecords, 'next', function(keys) {
        var l = keys.length;
        var delIt;
        delIt = function(id, tableName) {
            idbDelObj(id, tableName, function(success) {});
        };
        for (var i = 0; i < l; i++) {
            a = keys[i].split("/");
            id = a[2];
            if (a[1] === "n") {
                id = parseInt(id, 10);
            }
            delIt(id, tableName);
            if (i === (l - 1)) {
                if (callback) {
                    callback(true);
                }
            }
        }
        if (l === 0) {
            callback(true);
        }
    });
};

And here are some of the ways I 've been using it:

this.findAllLocations = function(callback) {
    var tableName = "Locations";
    var keyRange = null;
    var indexName = null;
    var maxRecords = 0;
    var sortOrder = 'next';
    idbFindObjs(tableName, keyRange, indexName, maxRecords, sortOrder, function(results) {
        callback(results);
    });
};

this.deleteLocationById = function(name, callback) {
    idbDelObj(name, "Locations", function(success) {
        callback(success);
    });
};

this.findBreakdownByScore = function(score_id, callback) {
    var tableName = "Breakdowns";
    var id = idbConvertId(score_id, tableName);
    var keyRange = IDBKeyRange.only(id);
    var indexName = 'scoreId';
    var maxRecords = 0;
    var sortOrder = 'next';
    idbFindObjs(tableName, keyRange, indexName, maxRecords, sortOrder, function(results) {
        callback(results);
    });
};