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:
Problems I've encountered so far:
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)onsuccess
callback is called - so each upgrade needs sequential chainingonsuccess
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; });
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);
});
});
}
});
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:
versionchange
transaction; versionchange
transaction, therefore we must distinguish between data and schema migrations and execute them in different ways, with distinct transactions.versionchange
transaction, but not via the usual db.transaction('readwrite', ...).objectstore(...)
method - this throws an exception. Instead use a reference to the versionchange
transaction..open(dbName, version)
only allows one versionchange
transaction, once it succeeds it's gone. This method is the only way to create versionchange
transactions.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; });
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With