Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

IndexedDB: upgrade with promises?

Just started my first project with IndexedDb, and I'm stumped trying to create a system for opening and upgrading the database on first use. I want to use promises (current the angularJs $q service but I'm flexible) to give me some guarantees about trapping any errors that occur and to reduce the mental overhead reasoning about failure modes. My requirements are:

  • The consumer calls some function to open and upgrade the database that returns a promise
  • The function performs all the upgrades/migrations necessary in sequence. If no errors occur, the promise is resolved with a connection to the database
  • If any error occurs at any stage, the promise is guaranteed to be rejected with the error
  • Adding a new migration/upgrade step is as easy as defining a function which performs the upgrade, all other concurrency concerns are taken care of by the 'framework'.

Problems I've encountered so far:

  • The onupgraderequired callback is not called if the DB doesn't need upgrading (so a promise that got resolved on upgrade complete will never be resolved if the DB doesn't need upgrading, and the calling code doesn't know if this will be the case when wiring up callbacks)
  • If one upgrade relies on another (e.g. populating a store you just created), you must wait until its onsuccess callback is called - so each upgrade needs sequential chaining
  • It appears that the delay for a promise to be executed after its predecessor in the chain resolves is enough to mark the 'transaction' as inactive before it's needed again (I think they're scheduled with 'nextTick', which may be the same mechanism that inactivated the transaction).
  • update if one upgrade relies on another, by the time the onsuccess callback of the first is called, the versionchange transaction is no longer active.

My current conclusion is that the API is fundamentally hostile to a promises-based approach. My best attempt is below (simplified a bit for easier reading). Where am I going wrong?

var newPromise = function(withDeferred) {
    var deferred = $q.defer();
    try {
        withDeferred(deferred);
    } catch (err) {
        deferred.reject(err); 
    }
    return deferred.promise;
};

var newTransactionPromise = function(getTransaction) {
    return newPromise(function(deferred) {
        var transaction = getTransaction();

        transaction.oncomplete = function(ev) { deferred.resolve(); };
        transaction.onabort = function(ev) { deferred.reject(transaction.error); };
    });
};

var migrations = [
    function(db) {
        return newTransactionPromise(function() {
            // throws: The database is not running a version change transaction.
            return db
                .createObjectStore("entries", { keyPath: 'id', autoIncrement: true })
                .transaction;
        });
    },
    function(db) {
        return newTransactionPromise(function()
        {
            var entryStore = db.transaction("entries", "readwrite").objectStore("entries");
            entryStore.add({ description: "First task" });
            return entryStore.transaction;
        });
    }
];

var upgradeAndOpen = function() {
    return newPromise(function(deferred) {
        var latest_version = migrations.length;
        var request = indexedDB.open("caesium", latest_version);

        request.onupgradeneeded = function(event) {
            try {
                // create an already resolved promise to start a chain
                var setupDeferred = $q.defer(); 
                setupDeferred.resolve();
                var setupComplete = setupDeferred.promise;

                for (var v = event.oldVersion; v < latest_version; v++)
                {
                    // Problem: the versionchange transaction will be 'inactive' before this promise is scheduled
                    var nextMigration = migrations[v].bind(this, request.result);
                    setupComplete = setupComplete.then(nextMigration);
                }

                setupComplete["catch"](deferred.reject);
            } catch (err) {
                deferred.reject(err);
            }
        };

        request.onerror = function(event) { deferred.reject(request.error); };
        request.onsuccess = function(event) { deferred.resolve(request.result); };
    });
};

upgradeAndOpen()["catch"](function(err) { $scope.status = err; });
like image 880
Tom Carver Avatar asked Nov 11 '22 02:11

Tom Carver


2 Answers

var open = function(name, ver) {
  return new Promise(function(yes, no) {
     var req = indexedDB.open(name, var);
     req.onupgradedneeded = function(res) {
       no(req);
       req.onsuccess = null; // for clarity
     };
     req.onsuccess = function() {
       yes(res.result);
     };
     req.onblocked = no;
  }
});

open('db name', 3).then(function(db) {
  // use db here

 }, function(req) {
   // version upgrade logic here

   if (req instanceof IDBResult) {
    return new Promise(function(yes, no) {
      req.transaction.createObjectStore('store_3');
      req.onsuccess = function() {
        yes(req.result);
      });
    });
  }
});
       
like image 122
Kyaw Tun Avatar answered Nov 14 '22 23:11

Kyaw Tun


I finally found a way to avoid all the nastiness of this API and have found a solution that exposes a clean promises-based interface and generalises to any number of database migrations. Key gotchas:

  • Schema changes can only be performed during a versionchange transaction; but data changes cannot be performed during a versionchange transaction, therefore we must distinguish between data and schema migrations and execute them in different ways, with distinct transactions. update data changes can be performed during a versionchange transaction, but not via the usual db.transaction('readwrite', ...).objectstore(...) method - this throws an exception. Instead use a reference to the versionchange transaction.
  • To allow arbitrary interleaving of schema creation and population, we must treat these as separate migration steps and only attempt one step once the transaction for the previous step has succeeded.
  • Explicit transaction management is explicitly prevented by the spec (https://dvcs.w3.org/hg/IndexedDB/raw-file/tip/Overview.html#transaction-concept ) which limits the extent to which transactions can be re-used as they are marked inactive once the event loop completes
  • Therefore method .open(dbName, version) only allows one versionchange transaction, once it succeeds it's gone. This method is the only way to create versionchange transactions
  • Therefore multiple migration steps require multiple successive calls to .open(dbName, version)
  • versionchange transactions block while other database connections are open, so every connection must be closed before attempting the next migration in the chain.

The code I came up with to negotiate all these gotchas is below.

var newPromise = function(withDeferred) {
    var deferred = $q.defer();
    try {
        withDeferred(deferred);
    } catch (err) {
        deferred.reject(err); 
    }
    return deferred.promise;
};

var newTransactionPromise = function(getTransaction) {
    return newPromise(function(deferred) {
        var transaction = getTransaction();

        transaction.oncomplete = function(ev) { deferred.resolve(); };
        transaction.onabort = function(ev) { deferred.reject(transaction.error); };
    });
};

var newMigrationPromise = function(dbName, version, migration) {
    return newPromise(function(deferred) {
        var request = indexedDB.open(dbName, version);

        // NB: caller must ensure upgrade callback always called
        request.onupgradeneeded = function(event) {
            var db = request.result;
            newTransactionPromise(
                function() {
                    migration(db, request.transaction);
                    return request.transaction;
                })
                .then(function() { db.close(); })
                .then(deferred.resolve, deferred.reject);
        };

        request.onerror = function(ev) { deferred.reject(request.error); };
    });
};

var migrations = [
    function(db, transaction) {
        db.createObjectStore("entries", { keyPath: 'id', autoIncrement: true });
    },
    function(db, transactionn) {
        db.createObjectStore("entries2", { keyPath: 'id', autoIncrement: true });
    },
    function(db, transaction) {
        var entryStore = transaction.objectStore("entries");

        entryStore.add({description: "First task"});
    }
];

var upgradeAndOpen = function() {
    return open()
        .then(function(db) {
            var version = db.version;
            db.close(); // this connection will block the upgrade (AFAICT)
            return version;
        })
        .then(function(version) {
            return newPromise(function(deferred) {
                // version when created but empty is v1
                // so after the first migration (index: 0) the version must be 2
                var migrationsPerformed = version - 1;
                var migrationCount = migrations.length;

                var previousMigration = newPromise(function(deferred) { deferred.resolve(); });

                for (var index = migrationsPerformed; index < migrationCount; index++)
                {
                    var performNextMigration = newMigrationPromise.bind(this, "caesium", index+2, migrations[index]);
                    previousMigration = previousMigration.then(performNextMigration);
                }

                previousMigration.then(deferred.resolve, deferred.reject);
            });
        })
        .then(open);
};

var open = function() {
    return newPromise(function(deferred) {
        var request = indexedDB.open("caesium");

        request.onsuccess = function(ev) { deferred.resolve(request.result); };
        request.onerror = function(ev) { deferred.reject(request.error); };
    });
};

upgradeAndOpen()
    .then(function() { $scope.status = "completed"; }, function(err) { $scope.status = err; });
like image 39
Tom Carver Avatar answered Nov 14 '22 21:11

Tom Carver