UNPKG

ydn.db

Version:

Javascript database library for IndexedDB, WebDatabase (WebSQL) and WebStorage (localStorage) storage mechanisms supporting version migration, advanced query and transaction workflow.

854 lines (725 loc) 26.7 kB
// Copyright 2012 YDN Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview IndexedDb connector. * * @author kyawtun@yathit.com (Kyaw Tun) */ goog.provide('ydn.db.con.IndexedDb'); goog.require('goog.async.DeferredList'); goog.require('ydn.db'); goog.require('ydn.db.base'); goog.require('ydn.db.con.IDatabase'); goog.require('ydn.db.schema.Database'); goog.require('ydn.error.ConstraintError'); goog.require('ydn.json'); /** * @see goog.db.IndexedDb * @see ydn.db.Storage for schema * * @param {number=} opt_size estimated database size. * @param {number=} opt_time_out connection time out. * @implements {ydn.db.con.IDatabase} * @constructor * @struct */ ydn.db.con.IndexedDb = function(opt_size, opt_time_out) { if (goog.isDef(opt_size)) { // https://developers.google.com/chrome/whitepapers/storage#asking_more // Quota Management API is not IndexedDB API and // this should not implement in this database API. /* webkitStorageInfo.requestQuota( webkitStorageInfo.PERSISTENT newQuotaInBytes, quotaCallback, errorCallback); */ if (opt_size > 5 * 1024 * 1024) { // no need to ask for 5 MB. goog.log.log(this.logger, goog.log.Level.WARNING, 'storage size request ignored, ' + 'use Quota Management API instead'); } } this.idx_db_ = null; this.time_out_ = opt_time_out || NaN; }; /** * @inheritDoc */ ydn.db.con.IndexedDb.prototype.connect = function(dbname, schema) { /** * @type {ydn.db.con.IndexedDb} */ var me = this; var df = new goog.async.Deferred(); var old_version = undefined; /** * This is final result of connection. It is either fail or connected * and only once. * @param {IDBDatabase} db database instance. * @param {Error=} opt_err error. */ var setDb = function(db, opt_err) { if (df.hasFired()) { goog.log.warning(me.logger, 'database already set.'); } else if (goog.isDef(opt_err)) { goog.log.warning(me.logger, opt_err ? opt_err.message : 'Error received.'); me.idx_db_ = null; df.errback(opt_err); } else { goog.asserts.assertObject(db, 'db'); me.idx_db_ = db; me.idx_db_.onabort = function(e) { goog.log.finest(me.logger, me + ': abort'); var request = /** @type {IDBRequest} */ (e.target); me.onError(request.error); }; me.idx_db_.onerror = function(e) { if (ydn.db.con.IndexedDb.DEBUG) { goog.global.console.log(e); } goog.log.finest(me.logger, me + ': error'); var request = /** @type {IDBRequest} */ (e.target); me.onError(request.error); }; /** * @this {null} * @param {IDBVersionChangeEvent} event event. */ me.idx_db_.onversionchange = function(event) { // Handle version changes while a web app is open in another tab // https://developer.mozilla.org/en-US/docs/IndexedDB/Using_IndexedDB# // Version_changes_while_a_web_app_is_open_in_another_tab // if (ydn.db.con.IndexedDb.DEBUG) { goog.global.console.log([this, event]); } goog.log.finest(me.logger, me + ' closing connection for onversionchange to: ' + event.version); if (me.idx_db_) { me.idx_db_.onabort = null; me.idx_db_.onblocked = null; me.idx_db_.onerror = null; me.idx_db_.onversionchange = null; me.onVersionChange(event); if (!event.defaultPrevented) { me.idx_db_.close(); me.idx_db_ = null; var e = new Error(); e.name = event.type; me.onFail(e); } } }; df.callback(parseFloat(old_version)); } }; /** * Migrate from current version to the given version. * @protected * @param {IDBDatabase} db database instance. * @param {IDBTransaction} trans transaction. * @param {boolean} is_caller_setversion call from set version. */ var updateSchema = function(db, trans, is_caller_setversion) { var action = is_caller_setversion ? 'changing' : 'upgrading'; goog.log.finer(me.logger, action + ' version to ' + db.version + ' from ' + old_version); // create store that we don't have previously for (var i = 0; i < schema.stores.length; i++) { // this is sync process. me.update_store_(db, trans, schema.stores[i]); } // delete stores var storeNames = /** @type {DOMStringList} */ (db.objectStoreNames); for (var n = storeNames.length, i = 0; i < n; i++) { if (!schema.hasStore(storeNames[i])) { db.deleteObjectStore(storeNames[i]); goog.log.finer(me.logger, 'store: ' + storeNames[i] + ' deleted.'); } } }; var version = schema.getVersion(); // In chrome, version is taken as description. goog.log.log(this.logger, goog.log.Level.FINER, 'Opening database: ' + dbname + ' ver: ' + (schema.isAutoVersion() ? 'auto' : version)); /** * Currently in transaction stage, opening indexedDB return two format. * IDBRequest from old and IDBOpenDBRequest from new API. * @type {IDBOpenDBRequest|IDBRequest} */ var openRequest; if (!goog.isDef(version)) { // auto schema do not have version // Note: undefined is not 'not defined', i.e. open('name', undefined) // is not the same effect as open('name'); openRequest = ydn.db.base.indexedDb.open(dbname); } else { openRequest = ydn.db.base.indexedDb.open(dbname, version); // version could be number (new) or string (old). // casting is for old externs uncorrected defined as string // old version will think, version as description. } openRequest.onsuccess = function(ev) { /** * @type {IDBDatabase} */ var db = ev.target.result; if (!goog.isDef(old_version)) { old_version = db.version; } var msg = 'Database: ' + db.name + ', ver: ' + db.version + ' opened.'; goog.log.log(me.logger, goog.log.Level.FINER, msg); if (schema.isAutoVersion()) { // since there is no version, auto schema always need to validate /** * Validate given schema and schema of opened database. * @param {ydn.db.schema.Database} db_schema schema. */ var schema_updater = function(db_schema) { // add existing object store if (schema instanceof ydn.db.schema.EditableDatabase) { var editable = /** @type {ydn.db.schema.EditableDatabase} */ (schema); for (var i = 0; i < db_schema.stores.length; i++) { if (!editable.hasStore(db_schema.stores[i].getName())) { editable.addStore(db_schema.stores[i].clone()); } } } var diff_msg = schema.difference(db_schema, false, true); if (diff_msg.length > 0) { goog.log.log(me.logger, goog.log.Level.FINER, 'Schema change require for difference in ' + diff_msg); var on_completed = function(t, e) { if (t == ydn.db.base.TxEventTypes.COMPLETE) { setDb(db); } else { goog.log.error(me.logger, 'Fail to update version on ' + db.name + ':' + db.version); setDb(null, e); } }; var next_version = goog.isNumber(db.version) ? db.version + 1 : 1; if ('IDBOpenDBRequest' in goog.global) { db.close(); var req = ydn.db.base.indexedDb.open( dbname, /** @type {number} */ (next_version)); req.onupgradeneeded = function(ev) { var db = ev.target.result; goog.log.log(me.logger, goog.log.Level.FINER, 're-open for version ' + db.version); updateSchema(db, req['transaction'], false); }; req.onsuccess = function(ev) { setDb(ev.target.result); }; req.onerror = function(e) { goog.log.log(me.logger, goog.log.Level.FINER, me + ': fail.'); setDb(null); }; } else { var ver_request = db.setVersion(next_version + ''); ver_request.onfailure = function(e) { goog.log.warning(me.logger, 'migrating from ' + db.version + ' to ' + next_version + ' failed.'); setDb(null, e); }; var trans = ver_request['transaction']; ver_request.onsuccess = function(e) { ver_request['transaction'].oncomplete = tr_on_complete; updateSchema(db, ver_request['transaction'], true); }; var tr_on_complete = function(e) { // for old format. // by reopening the database, we make sure that we are not in // version change state since transaction cannot open during // version change state. // db.close(); // necessary - cause error ? var reOpenRequest = ydn.db.base.indexedDb.open(dbname); reOpenRequest.onsuccess = function(rev) { var db = rev.target.result; goog.log.log(me.logger, goog.log.Level.FINER, me + ': OK.'); setDb(db); }; reOpenRequest.onerror = function(e) { goog.log.log(me.logger, goog.log.Level.FINER, me + ': fail.'); setDb(null); }; }; if (goog.isDefAndNotNull(ver_request['transaction'])) { ver_request['transaction'].oncomplete = tr_on_complete; } } } else { setDb(db); } }; me.getSchema(schema_updater, undefined, db); } else if (schema.getVersion() > db.version) { // in old format, db.version will be a string. type coercion should work // here goog.asserts.assertFunction(db['setVersion'], 'Expecting IDBDatabase in old format'); var version = /** @type {*} */ (schema.getVersion()); var ver_request = db.setVersion(/** @type {string} */ (version)); ver_request.onfailure = function(e) { goog.log.warning(me.logger, 'migrating from ' + db.version + ' to ' + schema.getVersion() + ' failed.'); setDb(null, e); }; ver_request.onsuccess = function(e) { updateSchema(db, ver_request['transaction'], true); }; } else { if (schema.getVersion() == db.version) { goog.log.log(me.logger, goog.log.Level.FINER, 'database version ' + db.version + ' ready to go'); } else { // this will not happen according to IDB spec. goog.log.warning(me.logger, 'connected database version ' + db.version + ' is higher than requested version.'); } /** * Validate given schema and schema of opened database. * @param {ydn.db.schema.Database} db_schema schema. */ var validator = function(db_schema) { var diff_msg = schema.difference(db_schema, false, true); if (diff_msg.length > 0) { goog.log.log(me.logger, goog.log.Level.FINER, diff_msg); setDb(null, new ydn.error.ConstraintError('different schema: ' + diff_msg)); } else { setDb(db); } }; me.getSchema(validator, undefined, db); } }; openRequest.onupgradeneeded = function(ev) { var db = ev.target.result; old_version = NaN; goog.log.log(this.logger, goog.log.Level.FINER, 'upgrade needed for version ' + db.version); updateSchema(db, openRequest['transaction'], false); }; openRequest.onerror = function(ev) { var ver = goog.isDef(schema.version) ? ' with version ' + schema.version : ''; var msg = 'open request to database "' + dbname + '" ' + ver + ' cause error of ' + openRequest.error.name; if (ydn.db.con.IndexedDb.DEBUG) { goog.global.console.log([ev, openRequest]); } goog.log.error(me.logger, msg); setDb(null, ev); }; openRequest.onblocked = function(ev) { if (ydn.db.con.IndexedDb.DEBUG) { goog.global.console.log([ev, openRequest]); } goog.log.error(me.logger, 'database ' + dbname + ' ' + schema.version + ' block, close other connections.'); // should we reopen again after some time? setDb(null, ev); }; // check for long database connection if (goog.isNumber(this.time_out_) && !isNaN(this.time_out_)) { setTimeout(function() { if (openRequest.readyState != 'done') { // what we observed is chrome attached error object to openRequest // but did not call any of over listening events. var msg = me + ': database state is still ' + openRequest.readyState; goog.log.error(me.logger, msg); setDb(null, new ydn.db.TimeoutError('connection timeout after ' + me.time_out_)); } }, this.time_out_); } return df; }; /** * @protected * @define {boolean} turn on debug flag to dump object. */ ydn.db.con.IndexedDb.DEBUG = false; /** * @final * @return {boolean} return indexedDB support on run time. */ ydn.db.con.IndexedDb.isSupported = function() { return !!ydn.db.base.indexedDb; }; /** * Timeout. * @type {number} * @private */ ydn.db.con.IndexedDb.prototype.time_out_ = 3 * 60 * 1000; /** * @inheritDoc */ ydn.db.con.IndexedDb.prototype.onFail = function(e) {}; /** * @inheritDoc */ ydn.db.con.IndexedDb.prototype.onError = function(e) {}; /** * @inheritDoc */ ydn.db.con.IndexedDb.prototype.onVersionChange = function(e) {}; /** * @return {string} storage mechanism type. */ ydn.db.con.IndexedDb.prototype.getType = function() { return ydn.db.base.Mechanisms.IDB; }; /** * Return database object, on if it is ready. * @final * @return {IDBDatabase} this instance. */ ydn.db.con.IndexedDb.prototype.getDbInstance = function() { // no checking for closing status. caller should know it. return this.idx_db_ || null; }; /** * @inheritDoc */ ydn.db.con.IndexedDb.prototype.isReady = function() { return !!this.idx_db_; }; /** * @protected * @type {goog.log.Logger} logger. */ ydn.db.con.IndexedDb.prototype.logger = goog.log.getLogger('ydn.db.con.IndexedDb'); /** * @private * @type {IDBDatabase} */ ydn.db.con.IndexedDb.prototype.idx_db_ = null; /** * @inheritDoc */ ydn.db.con.IndexedDb.prototype.getVersion = function() { return this.idx_db_ ? parseFloat(this.idx_db_.version) : undefined; }; /** * @inheritDoc */ ydn.db.con.IndexedDb.prototype.getSchema = function(callback, trans, db) { // console.log(this + ' getting schema'); /** * @type {IDBDatabase} */ var idb = /** @type {IDBDatabase} */ (db) || this.idx_db_; var mode = ydn.db.base.TransactionMode.READ_ONLY; if (!goog.isDef(trans)) { var names = []; for (var i = idb.objectStoreNames.length - 1; i >= 0; i--) { names[i] = idb.objectStoreNames[i]; } if (names.length == 0) { // http://www.w3.org/TR/IndexedDB/#widl-IDBDatabase-transaction- // IDBTransaction-any-storeNames-DOMString-mode // // InvalidAccessError: The function was called with an empty list of // store names callback(new ydn.db.schema.Database(idb.version)); return; } trans = idb.transaction(names, /** @type {number} */ (mode)); } else if (goog.isNull(trans)) { if (idb.objectStoreNames.length == 0) { callback(new ydn.db.schema.Database(idb.version)); return; } else { throw new ydn.error.InternalError(); } } else { //goog.global.console.log(['trans', trans]); idb = trans['db']; } /** @type {DOMStringList} */ var objectStoreNames = /** @type {DOMStringList} */ (idb.objectStoreNames); var stores = []; var n = objectStoreNames.length; for (var i = 0; i < n; i++) { /** * @type {IDBObjectStore} */ var objStore = trans.objectStore(objectStoreNames[i]); var indexes = []; for (var j = 0, ni = objStore.indexNames.length; j < ni; j++) { /** * @type {IDBIndex} */ var index = objStore.index(objStore.indexNames[j]); indexes[j] = new ydn.db.schema.Index(index.keyPath, undefined, index.unique, index.multiEntry, index.name); } stores[i] = new ydn.db.schema.Store(objStore.name, objStore.keyPath, objStore.autoIncrement, undefined, indexes); } var schema = new ydn.db.schema.Database(/** @type {number} */ (idb.version), stores); callback(schema); }; /** * * @param {IDBDatabase} db database. * @param {IDBTransaction} trans transaction. * @param {ydn.db.schema.Store} store_schema store schema. * @private */ ydn.db.con.IndexedDb.prototype.update_store_ = function(db, trans, store_schema) { goog.log.log(this.logger, goog.log.Level.FINEST, 'Creating Object Store for ' + store_schema.getName() + ' keyPath: ' + store_schema.getKeyPath()); var objectStoreNames = /** @type {DOMStringList} */ (db.objectStoreNames); /** * @return {IDBObjectStore} */ var createAObjectStore = function() { // IE10 is picky on optional parameters of keyPath. If it is undefined, // it must not be defined. var options = {'autoIncrement': !!store_schema.isAutoIncrement()}; if (goog.isDefAndNotNull(store_schema.getKeyPath())) { options['keyPath'] = store_schema.getKeyPath(); } // try/cache don't add benefit. // try { return db.createObjectStore(store_schema.getName(), options); // } catch (e) { // if (goog.DEBUG && e.name == 'InvalidAccessError') { // throw new ydn.db.InvalidAccessError('creating store for ' + // store_schema.getName() + ' of keyPath: ' + // store_schema.getKeyPath() + ' and autoIncrement: ' + // store_schema.isAutoIncrement()); // } else if (goog.DEBUG && e.name == 'ConstraintError') { // // store already exist. // throw new ydn.error.ConstraintError('creating store for ' + // store_schema.getName()); // } else { // throw e; // } // } }; /** * @type {IDBObjectStore} */ var store; if (objectStoreNames.contains(store_schema.getName())) { // already have the store, just update indexes store = trans.objectStore(store_schema.getName()); var keyPath = store_schema.getKeyPath() || ''; var store_keyPath = store.keyPath || ''; if (!!ydn.db.schema.Index.compareKeyPath(keyPath, store_keyPath)) { db.deleteObjectStore(store_schema.getName()); goog.log.log(this.logger, goog.log.Level.WARNING, 'store: ' + store_schema.getName() + ' deleted due to keyPath change.'); store = createAObjectStore(); } else if (goog.isBoolean(store.autoIncrement) && goog.isBoolean(store_schema.isAutoIncrement()) && store.autoIncrement != store_schema.isAutoIncrement()) { db.deleteObjectStore(store_schema.getName()); goog.log.log(this.logger, goog.log.Level.WARNING, 'store: ' + store_schema.getName() + ' deleted due to autoIncrement change.'); store = createAObjectStore(); } var indexNames = /** @type {DOMStringList} */ (store.indexNames); // check for new generator index for (var j = 0; j < store_schema.countIndex(); j++) { var index = store_schema.index(j); if (!indexNames.contains(index.getName()) && index.isGeneratorIndex()) { // generator index are only created on put, not on existing one, // instead of deleting all record, we could reindex them. store.clear(); goog.log.log(this.logger, goog.log.Level.WARNING, 'store: ' + store_schema.getName() + ' cleared since generator index need re-indexing.'); } } var created = 0; var deleted = 0; var modified = 0; for (var j = 0; j < store_schema.countIndex(); j++) { var index = store_schema.index(j); var need_create = false; if (indexNames.contains(index.getName())) { var store_index = store.index(index.getName()); // NOTE: Some browser (read: IE10) does not expose multiEntry // attribute in the index object. var dif_unique = goog.isDefAndNotNull(store_index.unique) && goog.isDefAndNotNull(index.unique) && store_index.unique != index.unique; var dif_multi = goog.isDefAndNotNull(store_index.multiEntry) && goog.isDefAndNotNull(index.multiEntry) && store_index.multiEntry != index.multiEntry; var dif_key_path = goog.isDefAndNotNull(store_index.keyPath) && goog.isDefAndNotNull(index.keyPath) && !!ydn.db.schema.Index.compareKeyPath( store_index.keyPath, index.keyPath); if (dif_unique || dif_multi || dif_key_path) { // console.log('delete index ' + index.name + ' on ' + store.name); store.deleteIndex(index.getName()); need_create = true; created--; modified++; } } else if (index.getType() != ydn.db.schema.DataType.BLOB) { // BLOB column data type, used in websql, is not index. need_create = true; } if (need_create) { if (index.unique || index.multiEntry) { var idx_options = { unique: index.unique, multiEntry: index.multiEntry}; store.createIndex(index.getName(), // todo: remove this casting after externs is updated. /** @type {string} */ (index.keyPath), idx_options); } else { store.createIndex(index.getName(), /** @type {string} */ (index.keyPath)); } created++; } } for (var j = 0; j < indexNames.length; j++) { if (!store_schema.hasIndex(indexNames[j])) { store.deleteIndex(indexNames[j]); deleted++; } } goog.log.log(this.logger, goog.log.Level.FINEST, 'Updated store: ' + store.name + ', ' + created + ' index created, ' + deleted + ' index deleted, ' + modified + ' modified.'); } else { store = createAObjectStore(); for (var j = 0; j < store_schema.countIndex(); j++) { var index = store_schema.index(j); if (index.getType() == ydn.db.schema.DataType.BLOB) { goog.log.log(this.logger, goog.log.Level.INFO, 'Index ' + index + ' of blob data type ignored.'); continue; } goog.log.finest(this.logger, 'Creating index: ' + index + ' for ' + store_schema.getName()); if (index.unique || index.multiEntry) { var idx_options = {unique: index.unique, multiEntry: index.multiEntry}; store.createIndex(index.getName(), index.keyPath, idx_options); } else { store.createIndex(index.getName(), index.keyPath); } } goog.log.finer(this.logger, 'Created store: ' + store); } }; /** * When DB is ready, fnc will be call with a fresh transaction object. Fnc must * put the result to 'result' field of the transaction object on success. If * 'result' field is not set, it is assumed * as failed. * @protected * @param {function(IDBTransaction)|Function} fnc transaction function. * @param {Array.<string>} scopes list of stores involved in the * transaction. If null, all stores is used. * @param {ydn.db.base.TransactionMode} mode mode. * @param {function(ydn.db.base.TxEventTypes, *)} on_completed * on complete handler. */ ydn.db.con.IndexedDb.prototype.doTransaction = function(fnc, scopes, mode, on_completed) { /** * * @type {IDBDatabase} */ var db = this.idx_db_; if (!scopes) { scopes = []; for (var i = db.objectStoreNames.length - 1; i >= 0; i--) { scopes[i] = db.objectStoreNames[i]; } } if (scopes.length == 0) { fnc(null); // should we just throw error? return; // opening without object store name will cause InvalidAccessError } var tx = db.transaction(scopes, /** @type {number} */ (mode)); tx.oncomplete = function(event) { on_completed(ydn.db.base.TxEventTypes.COMPLETE, event); }; // NOTE: Let downstream `tr` module handle transaction error event. // future more, database instance will receive and dispatch error event. // tx.onerror = function(event) {}; tx.onabort = function(event) { on_completed(ydn.db.base.TxEventTypes.ABORT, event); }; fnc(tx); fnc = null; }; /** * Close the connection. */ ydn.db.con.IndexedDb.prototype.close = function() { goog.log.log(this.logger, goog.log.Level.FINEST, this + ' closing connection'); this.idx_db_.close(); // IDB return void. }; if (goog.DEBUG) { /** * @override */ ydn.db.con.IndexedDb.prototype.toString = function() { var s = this.idx_db_ ? this.idx_db_.name + ':' + this.idx_db_.version : ''; return 'IndexedDB:' + s; }; /** * Handy debug function in testing on Chrome to delete all IDB databases. */ ydn.db.con.IndexedDb.deleteAllDatabases = function() { var req = window.indexedDB['webkitGetDatabaseNames'](); req.onsuccess = function(e) { var names = e.target.result; for (var i = 0; i < names.length; i++) { window.console.info('deleting ' + names[i]); window.indexedDB.deleteDatabase(names[i]); } }; }; } /** * Delete database. * @param {string} db_name name of database. * @param {string=} opt_type delete only specific types. * @return {ydn.db.Request} */ ydn.db.con.IndexedDb.deleteDatabase = function(db_name, opt_type) { if (ydn.db.base.indexedDb && (!opt_type || opt_type == ydn.db.base.Mechanisms.IDB)) { var req = ydn.db.base.indexedDb.deleteDatabase(db_name); var df = new ydn.db.Request(ydn.db.Request.Method.VERSION_CHANGE); req.onblocked = function(e) { df.notify(e); }; req.onerror = function(e) { df.errback(e); }; req.onsuccess = function(e) { df.callback(e); }; return df; } else { return null; } }; ydn.db.databaseDeletors.push(ydn.db.con.IndexedDb.deleteDatabase);