IndexedDB on iOS 8 - Broken Bad

Let me begin by saying that credit for this find goes to @jonnyknowsbest on Twitter and his SO post here: Primary Key issue on iOS8 implementation of IndexedDb. I did my research into this issue early this morning and I hope that I, and jonny, are both wrong. I'd love to be wrong about this. Unfortunately, I don't think that is the case.

So, as you know, iOS 8 finally brought IndexedDB to Mobile Safari. I may be biased, but I find features like this far more useful than CSS updates. Not to say that I don't appreciate them, but to me, deep data storage on the client is something that is more practical and useful to more people. Of course, I work for a company that is all about designers and not developers, so what do I know? ;)

Unfortunately, it seems as if Apple may have screwed up their implementation of IndexedDB - and screwed it up bad. Like real bad. If you read the SO post I linked to above, you will see that he was using assigned IDs and discovered that if you assigned the same ID to data in two datastores, then the data inserted in the first objectstore is removed. Let me restate that just to be obvious.

Imagine you have two object stores, people and beer. You want to add an object to both, and in both cases, you use a hard coded primary key of 1. When you do, no error is thrown, but the person object is deleted. Only beer remains. (Not the worst result...) Here is a full example showing this bug in action.

<!doctype html>
<html>
<head>
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
</head>
    
<body>

<script>
var db;

function indexedDBOk() {
    return "indexedDB" in window;
}

document.addEventListener("DOMContentLoaded", function() {

    //No support? Go in the corner and pout.
    if(!indexedDBOk()) return;

    var openRequest = indexedDB.open("ios8b",1);

    openRequest.onupgradeneeded = function(e) {
        var thisDB = e.target.result;

        console.log("running onupgradeneeded");

        if(!thisDB.objectStoreNames.contains("people")) {
            thisDB.createObjectStore("people", {keyPath:"id"});
        }

        if(!thisDB.objectStoreNames.contains("notes")) {
            thisDB.createObjectStore("notes", {keyPath:"uid"});
        }

    }

    openRequest.onsuccess = function(e) {
        console.log("running onsuccess");

        db = e.target.result;

        console.log("Current Object Stores");
        console.dir(db.objectStoreNames);

        //Listen for add clicks
        document.querySelector("#addButton").addEventListener("click", addPerson, false);
    }   

    openRequest.onerror = function(e) {
        //Do something for the error
    }


},false);


function addPerson(e) {
    console.log("About to add person and note");

    var id = Number(document.querySelector("#key").value);
    
    //Get a transaction
    //default for OS list is all, default for type is read
    var transaction = db.transaction(["people"],"readwrite");
    //Ask for the objectStore
    var store = transaction.objectStore("people");

    //Define a person
    var person = {
        name:"Ray",
        created:new Date().toString(),
        id:id
    }

    //Perform the add
    var request = store.add(person);

    request.onerror = function(e) {
        console.log("Error",e.target.error.name);
        //some type of error handler
    }

    request.onsuccess = function(e) {
        console.log("Woot! Did it");
    }
    
    //Define a note
    var note = {
        note:"note",
        created:new Date().toString(),
        uid:id
    }

    var transaction2 = db.transaction(["notes"],"readwrite");
    //Ask for the objectStore
    var store2 = transaction2.objectStore("notes");

    //Perform the add
    var request2 = store2.add(note);

    request2.onerror = function(e) {
        console.log("Error",e.target.error.name);
        //some type of error handler
    }

    request2.onsuccess = function(e) {
        console.log("Woot! Did it");
    }
    
}
</script>

enter key: <input id="key"><br/>
<button id="addButton">Add Data</button>

</body>
</html>

This demo uses a simple form to ask you for a PK. When you click the button, it then adds a static person and note object using the value you gave for a PK. When you run this, no error is thrown. The success handler for both operations is run. But the data you created for the person is gone. This is horrible.

But wait! Who uses defined primary keys? Only nerds! I like auto incrementing keys, so why not just switch to that? Simple enough, right? I made a new demo, with a new database, and modified my objectstores:

     if(!thisDB.objectStoreNames.contains("people")) {
            thisDB.createObjectStore("people", {autoIncrement:true});
        }

        if(!thisDB.objectStoreNames.contains("notes")) {
            thisDB.createObjectStore("notes", {autoIncrement:true});
        }

And the same damn error occurs. I kid you not. Ok, fine iOS. So I then tried something else. According to the spec, you can create a transaction with multiple objectstores. I thought, maybe if I did that, iOS would handle the inserts better. So let's try this:

var transaction = db.transaction(["people","notes"],"readwrite");

But this threw an error: DOM IDBDatabase Exception 8: An operation failed because the requested database object could not be found.

Ok, so next I thought - what if we used autoIncrement and different key names. Maybe the key name being the same was confusing things:

     if(!thisDB.objectStoreNames.contains("people")) {
            thisDB.createObjectStore("people", {autoIncrement:true,keyPath:"appleisshit"});
        }

        if(!thisDB.objectStoreNames.contains("notes")) {
            thisDB.createObjectStore("notes", {autoIncrement:true,keyPath:"id"});
        }

Nope, same error. So... finally I gave up. I specified an ID number and prefixed it with a string.


function addPerson(e) {
    console.log("About to add person and note");

    var id = document.querySelector("#key").value;
    
    //Get a transaction
    //default for OS list is all, default for type is read
    var transaction = db.transaction(["people"],"readwrite");
    //Ask for the objectStore
    var store = transaction.objectStore("people");

    //Define a person
    var person = {
        name:"Ray",
        created:new Date().toString(),
        id:"people/"+id
    }

    //Perform the add
    var request = store.add(person);

    request.onerror = function(e) {
        console.log("Error",e.target.error.name);
        //some type of error handler
    }

    request.onsuccess = function(e) {
        console.log("Woot! Did it");
    }
    
    //Define a note
    var note = {
        note:"note",
        created:new Date().toString(),
        uid:"notes/"+id
    }

    var transaction2 = db.transaction(["notes"],"readwrite");
    //Ask for the objectStore
    var store2 = transaction2.objectStore("notes");

    //Perform the add
    var request2 = store2.add(note);

    request2.onerror = function(e) {
        console.log("Error",e.target.error.name);
        //some type of error handler
    }

    request2.onsuccess = function(e) {
        console.log("Woot! Did it");
    }
    
}

This worked. Of course, you still have the suck part of creating your own keys. You can, however, ask the objectstore for the size and simply increment yourself. I wrote up a new version that does this. This seems to work well and for now is what I'd recommend. It works fine in Chrome too so it isn't "harmful" to use this workaround.

<!doctype html>
<html>
<head>
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
</head>
    
<body>

<script>
var db;

function indexedDBOk() {
    return "indexedDB" in window;
}

document.addEventListener("DOMContentLoaded", function() {

    //No support? Go in the corner and pout.
    if(!indexedDBOk()) return;

    var openRequest = indexedDB.open("ios8_final3",1);

    openRequest.onupgradeneeded = function(e) {
        var thisDB = e.target.result;

        console.log("running onupgradeneeded");

        if(!thisDB.objectStoreNames.contains("people")) {
            thisDB.createObjectStore("people", {keyPath:"id"});
        }

        if(!thisDB.objectStoreNames.contains("notes")) {
            thisDB.createObjectStore("notes", {keyPath:"uid"});
        }

    }

    openRequest.onsuccess = function(e) {
        console.log("running onsuccess");

        db = e.target.result;

        console.log("Current Object Stores");
        console.dir(db.objectStoreNames);

        //Listen for add clicks
        document.querySelector("#addButton").addEventListener("click", addPerson, false);
    }   

    openRequest.onerror = function(e) {
        //Do something for the error
    }


},false);


function addPerson(e) {
    console.log("About to add person and note");


    //Define a person
    var person = {
        name:"Ray",
        created:new Date().toString(),
    }
    
    //Perform the add
    db.transaction(["people"],"readwrite").objectStore("people").count().onsuccess = function(event) {
        var total = event.target.result;
        console.log(total);
        person.id = "person/" + (total+1);
        
        var request = db.transaction(["people"],"readwrite").objectStore("people").add(person);
        
        request.onerror = function(e) {
            console.log("Error",e.target.error.name);
            //some type of error handler
        }

        request.onsuccess = function(e) {
            console.log("Woot! Did it");
        }

    }

    //Define a note
    var note = {
        note:"note",
        created:new Date().toString(),
    }

    db.transaction(["notes"],"readwrite").objectStore("notes").count().onsuccess = function(event) {
        var total = event.target.result;
        console.log(total);
        note.uid = "notes/" + (total+1);
        
        var request = db.transaction(["notes"],"readwrite").objectStore("notes").add(note);
        
        request.onerror = function(e) {
            console.log("Error",e.target.error.name);
            //some type of error handler
        }

        request.onsuccess = function(e) {
            console.log("Woot! Did it");
        }

    }
    
}
</script>

<button id="addButton">Add Data</button>

</body>
</html>

I hope this helps folks. As I said, maybe I'm being stupid and missing something obvious. I hope so. But considering that iOS 8 also broke file uploads (both "regular" and via XHR2), it isn't too surprising that this could be broken as well. I'm going to file a bug report now. If their reporting system supports sharing the URL, I'll do so in a comment.

Like This?

If you like this article, please consider visiting my Amazon Wishlist or donating via PayPal to show your support. You can also subscribe to the email feed to get notified of new posts.