UNPKG

@wireapp/cryptobox

Version:

High-level API with persistent storage for Proteus.

1,000 lines (981 loc) 49.9 kB
/* ========================================================================== * dexie-observable.js * ========================================================================== * * Dexie addon for observing database changes not just on local db instance * but also on other instances, tabs and windows. * * Comprises a base framework for dexie-syncable.js * * By David Fahlander, david.fahlander@gmail.com, * Nikolas Poniros, https://github.com/nponiros * * ========================================================================== * * Version 1.0.0-beta.4, Mon Oct 02 2017 * * http://dexie.org * * Apache License Version 2.0, January 2004, http://www.apache.org/licenses/ * */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('dexie')) : typeof define === 'function' && define.amd ? define(['dexie'], factory) : (global.Dexie = global.Dexie || {}, global.Dexie.Observable = factory(global.Dexie)); }(this, (function (Dexie) { 'use strict'; Dexie = 'default' in Dexie ? Dexie['default'] : Dexie; function nop() { } function promisableChain(f1, f2) { if (f1 === nop) return f2; return function () { var res = f1.apply(this, arguments); if (res && typeof res.then === 'function') { var thiz = this, args = arguments; return res.then(function () { return f2.apply(thiz, args); }); } return f2.apply(this, arguments); }; } function createUUID() { // Decent solution from http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript var d = Date.now(); var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = (d + Math.random() * 16) % 16 | 0; d = Math.floor(d / 16); return (c === 'x' ? r : (r & 0x7 | 0x8)).toString(16); }); return uuid; } function initOverrideCreateTransaction(db, wakeupObservers) { return function overrideCreateTransaction(origFunc) { return function (mode, storenames, dbschema, parent) { if (db.dynamicallyOpened()) return origFunc.apply(this, arguments); // Don't observe dynamically opened databases. var addChanges = false; if (mode === 'readwrite' && storenames.some(function (storeName) { return dbschema[storeName] && dbschema[storeName].observable; })) { // At least one included store is a observable store. Make sure to also include the _changes store. addChanges = true; storenames = storenames.slice(0); // Clone if (storenames.indexOf("_changes") === -1) storenames.push("_changes"); // Otherwise, firefox will hang... (I've reported the bug to Mozilla@Bugzilla) } // Call original db._createTransaction() var trans = origFunc.call(this, mode, storenames, dbschema, parent); // If this transaction is bound to any observable table, make sure to add changes when transaction completes. if (addChanges) { trans._lastWrittenRevision = 0; trans.on('complete', function () { if (trans._lastWrittenRevision) { // Changes were written in this transaction. if (!parent) { // This is root-level transaction, i.e. a physical commit has happened. // Delay-trigger a wakeup call: if (wakeupObservers.timeoutHandle) clearTimeout(wakeupObservers.timeoutHandle); wakeupObservers.timeoutHandle = setTimeout(function () { delete wakeupObservers.timeoutHandle; wakeupObservers(trans._lastWrittenRevision); }, 25); } else { // This is just a virtual commit of a sub transaction. // Wait with waking up observers until root transaction has committed. // Make sure to mark root transaction so that it will wakeup observers upon commit. var rootTransaction = (function findRootTransaction(trans) { return trans.parent ? findRootTransaction(trans.parent) : trans; })(parent); rootTransaction._lastWrittenRevision = Math.max(trans._lastWrittenRevision, rootTransaction.lastWrittenRevision || 0); } } }); // Derive "source" property from parent transaction by default if (trans.parent && trans.parent.source) trans.source = trans.parent.source; } return trans; }; }; } function initWakeupObservers(db, Observable, localStorage) { return function wakeupObservers(lastWrittenRevision) { // Make sure Observable.latestRevision[db.name] is still below our value, now when some time has elapsed and other db instances in same window possibly could have made changes too. if (Observable.latestRevision[db.name] < lastWrittenRevision) { // Set the static property lastRevision[db.name] to the revision of the last written change. Observable.latestRevision[db.name] = lastWrittenRevision; // Wakeup ourselves, and any other db instances on this window: Dexie.ignoreTransaction(function () { Observable.on('latestRevisionIncremented').fire(db.name, lastWrittenRevision); }); // Observable.on.latestRevisionIncremented will only wakeup db's in current window. // We need a storage event to wakeup other windwos. // Since indexedDB lacks storage events, let's use the storage event from WebStorage just for // the purpose to wakeup db instances in other windows. if (localStorage) localStorage.setItem('Dexie.Observable/latestRevision/' + db.name, lastWrittenRevision); // In IE, this will also wakeup our own window. However, onLatestRevisionIncremented will work around this by only running once per revision id. } }; } // Change Types // Change Types var CREATE = 1; var UPDATE = 2; var DELETE = 3; function initCreatingHook(db, table) { return function creatingHook(primKey, obj, trans) { /// <param name="trans" type="db.Transaction"></param> var rv = undefined; if (primKey === undefined && table.schema.primKey.uuid) { primKey = rv = createUUID(); if (table.schema.primKey.keyPath) { Dexie.setByKeyPath(obj, table.schema.primKey.keyPath, primKey); } } var change = { source: trans.source || null, table: table.name, key: primKey === undefined ? null : primKey, type: CREATE, obj: obj }; var promise = db._changes.add(change).then(function (rev) { trans._lastWrittenRevision = Math.max(trans._lastWrittenRevision, rev); return rev; }); // Wait for onsuccess so that we have the primKey if it is auto-incremented and update the change item if so. this.onsuccess = function (resultKey) { if (primKey != resultKey) promise._then(function () { change.key = resultKey; db._changes.put(change); }); }; this.onerror = function () { // If the main operation fails, make sure to regret the change promise._then(function (rev) { // Will only happen if app code catches the main operation error to prohibit transaction from aborting. db._changes.delete(rev); }); }; return rv; }; } function initUpdatingHook(db, tableName) { return function updatingHook(mods, primKey, oldObj, trans) { /// <param name="trans" type="db.Transaction"></param> // mods may contain property paths with undefined as value if the property // is being deleted. Since we cannot persist undefined we need to act // like those changes is setting the value to null instead. var modsWithoutUndefined = {}; // As of current Dexie version (1.0.3) hook may be called even if it wouldn't really change. // Therefore we may do that kind of optimization here - to not add change entries if // there's nothing to change. var anythingChanged = false; var newObj = Dexie.deepClone(oldObj); for (var propPath in mods) { var mod = mods[propPath]; if (typeof mod === 'undefined') { Dexie.delByKeyPath(newObj, propPath); modsWithoutUndefined[propPath] = null; // Null is as close we could come to deleting a property when not allowing undefined. anythingChanged = true; } else { var currentValue = Dexie.getByKeyPath(oldObj, propPath); if (mod !== currentValue && JSON.stringify(mod) !== JSON.stringify(currentValue)) { Dexie.setByKeyPath(newObj, propPath, mod); modsWithoutUndefined[propPath] = mod; anythingChanged = true; } } } if (anythingChanged) { var change = { source: trans.source || null, table: tableName, key: primKey, type: UPDATE, mods: modsWithoutUndefined, oldObj: oldObj, obj: newObj }; var promise = db._changes.add(change); // Just so we get the correct revision order of the update... this.onsuccess = function () { promise._then(function (rev) { trans._lastWrittenRevision = Math.max(trans._lastWrittenRevision, rev); }); }; this.onerror = function () { // If the main operation fails, make sure to regret the change. promise._then(function (rev) { // Will only happen if app code catches the main operation error to prohibit transaction from aborting. db._changes.delete(rev); }); }; } }; } function initDeletingHook(db, tableName) { return function deletingHook(primKey, obj, trans) { /// <param name="trans" type="db.Transaction"></param> var promise = db._changes.add({ source: trans.source || null, table: tableName, key: primKey, type: DELETE, oldObj: obj }).then(function (rev) { trans._lastWrittenRevision = Math.max(trans._lastWrittenRevision, rev); return rev; }) .catch(function (e) { console.log(obj); console.log(e.stack); }); this.onerror = function () { // If the main operation fails, make sure to regret the change. // Using _then because if promise is already fullfilled, the standard then() would // do setTimeout() and we would loose the transaction. promise._then(function (rev) { // Will only happen if app code catches the main operation error to prohibit transaction from aborting. db._changes.delete(rev); }); }; }; } function initCrudMonitor(db) { // // The Creating/Updating/Deleting hook will make sure any change is stored to the changes table // return function crudMonitor(table) { /// <param name="table" type="db.Table"></param> if (table.hook._observing) return; table.hook._observing = true; var tableName = table.name; table.hook('creating').subscribe(initCreatingHook(db, table)); table.hook('updating').subscribe(initUpdatingHook(db, tableName)); table.hook('deleting').subscribe(initDeletingHook(db, tableName)); }; } function initOnStorage(Observable) { return function onStorage(event) { // We use the onstorage event to trigger onLatestRevisionIncremented since we will wake up when other windows modify the DB as well! if (event.key.indexOf("Dexie.Observable/") === 0) { var parts = event.key.split('/'); var prop = parts[1]; var dbname = parts[2]; if (prop === 'latestRevision') { var rev = parseInt(event.newValue, 10); if (!isNaN(rev) && rev > Observable.latestRevision[dbname]) { Observable.latestRevision[dbname] = rev; Dexie.ignoreTransaction(function () { Observable.on('latestRevisionIncremented').fire(dbname, rev); }); } } else if (prop.indexOf("deadnode:") === 0) { var nodeID = parseInt(prop.split(':')[1], 10); if (event.newValue) { Observable.on.suicideNurseCall.fire(dbname, nodeID); } } else if (prop === 'intercomm') { if (event.newValue) { Observable.on.intercomm.fire(dbname); } } } }; } function initOverrideOpen(db, SyncNode, crudMonitor) { return function overrideOpen(origOpen) { return function () { // // Make sure to subscribe to "creating", "updating" and "deleting" hooks for all observable tables that were created in the stores() method. // Object.keys(db._allTables).forEach(function (tableName) { var table = db._allTables[tableName]; if (table.schema.observable) { crudMonitor(table); } if (table.name === "_syncNodes") { table.mapToClass(SyncNode); } }); return origOpen.apply(this, arguments); }; }; } var Promise$1 = Dexie.Promise; function initIntercomm(db, Observable, SyncNode, mySyncNode, localStorage) { // // Intercommunication between nodes // // Enable inter-process communication between browser windows using localStorage storage event (is registered in Dexie.Observable) var requestsWaitingForReply = {}; /** * @param {string} type Type of message * @param message Message to send * @param {number} destinationNode ID of destination node * @param {{wantReply: boolean, isFailure: boolean, requestId: number}} options If {wantReply: true}, the returned promise will complete with the reply from remote. Otherwise it will complete when message has been successfully sent.</param> */ db.observable.sendMessage = function (type, message, destinationNode, options) { /// <param name="type" type="String">Type of message</param> /// <param name="message">Message to send</param> /// <param name="destinationNode" type="Number">ID of destination node</param> /// <param name="options" type="Object" optional="true">{wantReply: Boolean, isFailure: Boolean, requestId: Number}. If wantReply, the returned promise will complete with the reply from remote. Otherwise it will complete when message has been successfully sent.</param> options = options || {}; if (!mySyncNode.node) return options.wantReply ? Promise$1.reject(new Dexie.DatabaseClosedError()) : Promise$1.resolve(); // If caller doesn't want a reply, it won't catch errors either. var msg = { message: message, destinationNode: destinationNode, sender: mySyncNode.node.id, type: type }; Dexie.extend(msg, options); // wantReply: wantReply, success: !isFailure, requestId: ... return Dexie.ignoreTransaction(function () { var tables = ["_intercomm"]; if (options.wantReply) tables.push("_syncNodes"); // If caller wants a reply, include "_syncNodes" in transaction to check that there's a receiver there. Otherwise, new master will get it. var promise = db.transaction('rw', tables, function () { if (options.wantReply) { // Check that there is a receiver there to take the request. return db._syncNodes.where('id').equals(destinationNode).count(function (receiverAlive) { if (receiverAlive) return db._intercomm.add(msg); else return db._syncNodes.where('isMaster').above(0).first(function (masterNode) { msg.destinationNode = masterNode.id; return db._intercomm.add(msg); }); }); } else { // If caller doesn't need a response, we don't have to make sure that it gets one. return db._intercomm.add(msg); } }).then(function (messageId) { var rv = null; if (options.wantReply) { rv = new Promise$1(function (resolve, reject) { requestsWaitingForReply[messageId.toString()] = { resolve: resolve, reject: reject }; }); } if (localStorage) { localStorage.setItem("Dexie.Observable/intercomm/" + db.name, messageId.toString()); } Observable.on.intercomm.fire(db.name); return rv; }); if (!options.wantReply) { promise.catch(function () { }); return; } else { // Forward rejection to caller if it waits for reply. return promise; } }); }; // Send a message to all local _syncNodes db.observable.broadcastMessage = function (type, message, bIncludeSelf) { if (!mySyncNode.node) return; var mySyncNodeId = mySyncNode.node.id; Dexie.ignoreTransaction(function () { db._syncNodes.toArray(function (nodes) { return Promise$1.all(nodes .filter(function (node) { return node.type === 'local' && (bIncludeSelf || node.id !== mySyncNodeId); }) .map(function (node) { return db.observable.sendMessage(type, message, node.id); })); }).catch(function () { }); }); }; function consumeIntercommMessages() { // Check if we got messages: if (!mySyncNode.node) return Promise$1.reject(new Dexie.DatabaseClosedError()); return Dexie.ignoreTransaction(function () { return db.transaction('rw', '_intercomm', function () { return db._intercomm.where({ destinationNode: mySyncNode.node.id }).toArray(function (messages) { messages.forEach(function (msg) { return consumeMessage(msg); }); return db._intercomm.where('id').anyOf(messages.map(function (msg) { return msg.id; })).delete(); }); }); }); } function consumeMessage(msg) { if (msg.type === 'response') { // This is a response. Lookup pending request and fulfill its promise. var request = requestsWaitingForReply[msg.requestId.toString()]; if (request) { if (msg.isFailure) { request.reject(msg.message.error); } else { request.resolve(msg.message.result); } delete requestsWaitingForReply[msg.requestId.toString()]; } } else { // This is a message or request. Fire the event and add an API for the subscriber to use if reply is requested msg.resolve = function (result) { db.observable.sendMessage('response', { result: result }, msg.sender, { requestId: msg.id }); }; msg.reject = function (error) { db.observable.sendMessage('response', { error: error.toString() }, msg.sender, { isFailure: true, requestId: msg.id }); }; db.on.message.fire(msg); } } // Listener for 'intercomm' events // Gets fired when we get a 'storage' event from local storage or when sendMessage is called // 'storage' is used to communicate between tabs (sendMessage changes the localStorage to trigger the event) // sendMessage is used to communicate in the same tab and to trigger a storage event function onIntercomm(dbname) { // When storage event trigger us to check if (dbname === db.name) { consumeIntercommMessages().catch('DatabaseClosedError', function () { }); } } return { onIntercomm: onIntercomm, consumeIntercommMessages: consumeIntercommMessages }; } function overrideParseStoresSpec(origFunc) { return function (stores, dbSchema) { // Create the _changes and _syncNodes tables stores["_changes"] = "++rev"; stores["_syncNodes"] = "++id,myRevision,lastHeartBeat,&url,isMaster,type,status"; stores["_intercomm"] = "++id,destinationNode"; stores["_uncommittedChanges"] = "++id,node"; // For remote syncing when server returns a partial result. // Call default implementation. Will populate the dbSchema structures. origFunc.call(this, stores, dbSchema); // Allow UUID primary keys using $$ prefix on primary key or indexes Object.keys(dbSchema).forEach(function (tableName) { var schema = dbSchema[tableName]; if (schema.primKey.name.indexOf('$$') === 0) { schema.primKey.uuid = true; schema.primKey.name = schema.primKey.name.substr(2); schema.primKey.keyPath = schema.primKey.keyPath.substr(2); } }); // Now mark all observable tables Object.keys(dbSchema).forEach(function (tableName) { // Marked observable tables with "observable" in their TableSchema. if (tableName.indexOf('_') !== 0 && tableName.indexOf('$') !== 0) { dbSchema[tableName].observable = true; } }); }; } function deleteOldChanges(db) { // This is a background job and should never be done within // a caller's transaction. Use Dexie.ignoreTransaction() to ensure that. // We should not return the Promise but catch it ourselves instead. // To prohibit starving the database we want to lock transactions as short as possible // and since we're not in a hurry, we could do this job in chunks and reschedule a // continuation every 500 ms. var CHUNK_SIZE = 100; Dexie.ignoreTransaction(function () { return db._syncNodes.orderBy("myRevision").first(function (oldestNode) { return db._changes .where("rev").below(oldestNode.myRevision) .limit(CHUNK_SIZE) .primaryKeys(); }).then(function (keysToDelete) { if (keysToDelete.length === 0) return; // Done. return db._changes.bulkDelete(keysToDelete).then(function () { // If not done garbage collecting, reschedule a continuation of it until done. if (keysToDelete.length === CHUNK_SIZE) { // Limit reached. Changes are there are more job to do. Schedule again: setTimeout(function () { return db.isOpen() && deleteOldChanges(db); }, 500); } }); }); }).catch(function () { // The operation is not crucial. A failure could almost only be due to that database has been closed. // No need to log this. }); } /* ========================================================================== * dexie-observable.js * ========================================================================== * * Dexie addon for observing database changes not just on local db instance * but also on other instances, tabs and windows. * * Comprises a base framework for dexie-syncable.js * * By David Fahlander, david.fahlander@gmail.com, * Nikolas Poniros, https://github.com/nponiros * * ========================================================================== * * Version 1.0.0-beta.4, Mon Oct 02 2017 * * http://dexie.org * * Apache License Version 2.0, January 2004, http://www.apache.org/licenses/ * */ var global = self; /** class DatabaseChange * * Object contained by the _changes table. */ var DatabaseChange = Dexie.defineClass({ rev: Number, source: String, table: String, key: Object, type: Number, obj: Object, mods: Object, oldObj: Object // DELETE: oldObj contains the object deleted. UPDATE: oldObj contains the old object before updates applied. }); // Import some usable helper functions var override = Dexie.override; var Promise = Dexie.Promise; var browserIsShuttingDown = false; function Observable(db) { /// <summary> /// Extension to Dexie providing Syncronization capabilities to Dexie. /// </summary> /// <param name="db" type="Dexie"></param> var NODE_TIMEOUT = 20000, // 20 seconds before local db instances are timed out. This is so that old changes can be deleted when not needed and to garbage collect old _syncNodes objects. HIBERNATE_GRACE_PERIOD = 20000, // 20 seconds // LOCAL_POLL: The time to wait before polling local db for changes and cleaning up old nodes. // Polling for changes is a fallback only needed in certain circomstances (when the onstorage event doesnt reach all listeners - when different browser windows doesnt share the same process) LOCAL_POLL = 500, // 500 ms. In real-world there will be this value + the time it takes to poll(). A small value is needed in Workers where we cannot rely on storage event. HEARTBEAT_INTERVAL = NODE_TIMEOUT - 5000; var localStorage = Observable.localStorageImpl; /** class SyncNode * * Object contained in the _syncNodes table. */ var SyncNode = Dexie.defineClass({ //id: Number, myRevision: Number, type: String, lastHeartBeat: Number, deleteTimeStamp: Number, url: String, isMaster: Number, // Below properties should be extended in Dexie.Syncable. Not here. They apply to remote nodes only (type == "remote"): syncProtocol: String, syncContext: null, syncOptions: Object, connected: false, status: Number, appliedRemoteRevision: null, remoteBaseRevisions: [{ local: Number, remote: null }], dbUploadState: { tablesToUpload: [String], currentTable: String, currentKey: null, localBaseRevision: Number } }); db.observable = {}; db.observable.SyncNode = SyncNode; var wakeupObservers = initWakeupObservers(db, Observable, localStorage); var overrideCreateTransaction = initOverrideCreateTransaction(db, wakeupObservers); var crudMonitor = initCrudMonitor(db); var overrideOpen = initOverrideOpen(db, SyncNode, crudMonitor); var mySyncNode = { node: null }; var intercomm = initIntercomm(db, Observable, SyncNode, mySyncNode, localStorage); var onIntercomm = intercomm.onIntercomm; var consumeIntercommMessages = intercomm.consumeIntercommMessages; // Allow other addons to access the local sync node. May be needed by Dexie.Syncable. Object.defineProperty(db, "_localSyncNode", { get: function () { return mySyncNode.node; } }); var pollHandle = null, heartbeatHandle = null; if (Dexie.fake) { // This code will never run. // It's here just to enable auto-complete in visual studio - helps a lot when writing code. db.version(1).stores({ _syncNodes: "++id,myRevision,lastHeartBeat", _changes: "++rev", _intercomm: "++id,destinationNode", _uncommittedChanges: "++id,node" }); db._syncNodes.mapToClass(SyncNode); db._changes.mapToClass(DatabaseChange); mySyncNode.node = new SyncNode({ myRevision: 0, type: "local", lastHeartBeat: Date.now(), deleteTimeStamp: null }); } // // Override parsing the stores to add "_changes" and "_syncNodes" tables. // It also adds UUID support for the primary key and sets tables as observable tables. // db.Version.prototype._parseStoresSpec = override(db.Version.prototype._parseStoresSpec, overrideParseStoresSpec); // changes event on db: db.on.addEventType({ changes: 'asap', cleanup: [promisableChain, nop], message: 'asap' }); // // Override transaction creation to always include the "_changes" store when any observable store is involved. // db._createTransaction = override(db._createTransaction, overrideCreateTransaction); // If Observable.latestRevsion[db.name] is undefined, set it to 0 so that comparing against it always works. // You might think that it will always be undefined before this call, but in case another Dexie instance in the same // window with the same database name has been created already, this static property will already be set correctly. Observable.latestRevision[db.name] = Observable.latestRevision[db.name] || 0; // // Override open to setup hooks for db changes and map the _syncNodes table to class // db.open = override(db.open, overrideOpen); db.close = override(db.close, function (origClose) { return function () { if (db.dynamicallyOpened()) return origClose.apply(this, arguments); // Don't observe dynamically opened databases. // Teardown our framework. if (wakeupObservers.timeoutHandle) { clearTimeout(wakeupObservers.timeoutHandle); delete wakeupObservers.timeoutHandle; } Observable.on('latestRevisionIncremented').unsubscribe(onLatestRevisionIncremented); Observable.on('suicideNurseCall').unsubscribe(onSuicide); Observable.on('intercomm').unsubscribe(onIntercomm); Observable.on('beforeunload').unsubscribe(onBeforeUnload); // Inform other db instances in same window that we are dying: if (mySyncNode.node && mySyncNode.node.id) { Observable.on.suicideNurseCall.fire(db.name, mySyncNode.node.id); // Inform other windows as well: if (localStorage) { localStorage.setItem('Dexie.Observable/deadnode:' + mySyncNode.node.id.toString() + '/' + db.name, "dead"); // In IE, this will also wakeup our own window. cleanup() may trigger twice per other db instance. But that doesnt to anything. } mySyncNode.node.deleteTimeStamp = 1; // One millisecond after 1970. Makes it occur in the past but still keeps it truthy. mySyncNode.node.lastHeartBeat = 0; db._syncNodes.put(mySyncNode.node); // This async operation may be cancelled since the browser is closing down now. mySyncNode.node = null; } if (pollHandle) clearTimeout(pollHandle); pollHandle = null; if (heartbeatHandle) clearTimeout(heartbeatHandle); heartbeatHandle = null; return origClose.apply(this, arguments); }; }); // Override Dexie.delete() in order to delete Observable.latestRevision[db.name]. db.delete = override(db.delete, function (origDelete) { return function () { return origDelete.apply(this, arguments).then(function (result) { // Reset Observable.latestRevision[db.name] Observable.latestRevision[db.name] = 0; return result; }); }; }); // When db opens, make sure to start monitor any changes before other db operations will start. db.on("ready", function startObserving() { if (db.dynamicallyOpened()) return db; // Don't observe dynamically opened databases. return db.table("_changes").orderBy("rev").last(function (lastChange) { // Since startObserving() is called before database open() method, this will be the first database operation enqueued to db. // Therefore we know that the retrieved value will be This query will var latestRevision = (lastChange ? lastChange.rev : 0); mySyncNode.node = new SyncNode({ myRevision: latestRevision, type: "local", lastHeartBeat: Date.now(), deleteTimeStamp: null, isMaster: 0 }); if (Observable.latestRevision[db.name] < latestRevision) { // Side track . For correctness whenever setting Observable.latestRevision[db.name] we must make sure the event is fired if increased: // There are other db instances in same window that hasnt yet been informed about a new revision Observable.latestRevision[db.name] = latestRevision; Dexie.ignoreTransaction(function () { Observable.on.latestRevisionIncremented.fire(latestRevision); }); } // Add new sync node or if this is a reopening of the database after a close() call, update it. return db.transaction('rw', '_syncNodes', function () { return db._syncNodes .where('isMaster').equals(1) .first(function (currentMaster) { if (!currentMaster) { // There's no master. We must be the master mySyncNode.node.isMaster = 1; } else if (currentMaster.lastHeartBeat < Date.now() - NODE_TIMEOUT) { // Master have been inactive for too long // Take over mastership mySyncNode.node.isMaster = 1; currentMaster.isMaster = 0; return db._syncNodes.put(currentMaster); } }).then(function () { // Add our node to DB and start subscribing to events return db._syncNodes.add(mySyncNode.node).then(function () { Observable.on('latestRevisionIncremented', onLatestRevisionIncremented); // Wakeup when a new revision is available. Observable.on('beforeunload', onBeforeUnload); Observable.on('suicideNurseCall', onSuicide); Observable.on('intercomm', onIntercomm); // Start polling for changes and do cleanups: pollHandle = setTimeout(poll, LOCAL_POLL); // Start heartbeat heartbeatHandle = setTimeout(heartbeat, HEARTBEAT_INTERVAL); }); }); }).then(function () { cleanup(); }); }); }, true); // True means the on(ready) event will survive a db reopening (db.close() / db.open()). var handledRevision = 0; function onLatestRevisionIncremented(dbname, latestRevision) { if (dbname === db.name) { if (handledRevision >= latestRevision) return; // Make sure to only run once per revision. (Workaround for IE triggering storage event on same window) handledRevision = latestRevision; Dexie.vip(function () { readChanges(latestRevision).catch('DatabaseClosedError', function () { // Handle database closed error gracefully while reading changes. // Don't trigger 'unhandledrejection'. // Even though we intercept the close() method, it might be called when in the middle of // reading changes and then that flow will cancel with DatabaseClosedError. }); }); } } function readChanges(latestRevision, recursion, wasPartial) { // Whenever changes are read, fire db.on("changes") with the array of changes. Eventually, limit the array to 1000 entries or so (an entire database is // downloaded from server AFTER we are initiated. For example, if first sync call fails, then after a while we get reconnected. However, that scenario // should be handled in case database is totally empty we should fail if sync is not available) if (!recursion && readChanges.ongoingOperation) { // We are already reading changes. Prohibit a parallell execution of this which would lead to duplicate trigging of 'changes' event. // Instead, the callback in toArray() will always check Observable.latestRevision[db.name] to see if it has changed and if so, re-launch readChanges(). // The caller should get the Promise instance from the ongoing operation so that the then() method will resolve when operation is finished. return readChanges.ongoingOperation; } var partial = false; var ourSyncNode = mySyncNode.node; // Because mySyncNode can suddenly be set to null on database close, and worse, can be set to a new value if database is reopened. if (!ourSyncNode) { return Promise.reject(new Dexie.DatabaseClosedError()); } var LIMIT = 1000; var promise = db._changes.where("rev").above(ourSyncNode.myRevision).limit(LIMIT).toArray(function (changes) { if (changes.length > 0) { var lastChange = changes[changes.length - 1]; partial = (changes.length === LIMIT); db.on('changes').fire(changes, partial); ourSyncNode.myRevision = lastChange.rev; } else if (wasPartial) { // No more changes, BUT since we have triggered on('changes') with partial = true, // we HAVE TO trigger changes again with empty list and partial = false db.on('changes').fire([], false); } var ourNodeStillExists = false; return db._syncNodes.where(':id').equals(ourSyncNode.id).modify(function (syncNode) { ourNodeStillExists = true; syncNode.lastHeartBeat = Date.now(); // Update heart beat (not nescessary, but why not!) syncNode.deleteTimeStamp = null; // Reset "deleteTimeStamp" flag if it was there. syncNode.myRevision = Math.max(syncNode.myRevision, ourSyncNode.myRevision); }).then(function () { return ourNodeStillExists; }); }).then(function (ourNodeStillExists) { if (!ourNodeStillExists) { // My node has been deleted. We must have been lazy and got removed by another node. if (browserIsShuttingDown) { throw new Error("Browser is shutting down"); } else { db.close(); console.error("Out of sync"); // TODO: What to do? Reload the page? if (global.location) global.location.reload(true); throw new Error("Out of sync"); // Will make current promise reject } } // Check if more changes have come since we started reading changes in the first place. If so, relaunch readChanges and let the ongoing promise not // resolve until all changes have been read. if (partial || Observable.latestRevision[db.name] > ourSyncNode.myRevision) { // Either there were more than 1000 changes or additional changes where added while we were reading these changes, // In either case, call readChanges() again until we're done. return readChanges(Observable.latestRevision[db.name], (recursion || 0) + 1, partial); } }).finally(function () { delete readChanges.ongoingOperation; }); if (!recursion) { readChanges.ongoingOperation = promise; } return promise; } /** * The reason we need heartbeat in parallell with poll() is due to the risk of long-running * transactions while syncing changes from server to client in Dexie.Syncable. That transaction will * include _changes (which will block readChanges()) but not _syncNodes. So this heartbeat will go on * during that changes are being applied and update our lastHeartBeat property while poll() is waiting. * When cleanup() (who also is blocked by the sync) wakes up, it won't kill the master node because this * heartbeat job will have updated the master node's heartbeat during the long-running sync transaction. * * If we did not have this heartbeat, and a server send lots of changes that took more than NODE_TIMEOUT * (20 seconds), another node waking up after the sync would kill the master node and take over because * it would believe it was dead. */ function heartbeat() { heartbeatHandle = null; var currentInstance = mySyncNode.node && mySyncNode.node.id; if (!currentInstance) return; db.transaction('rw!', db._syncNodes, function () { db._syncNodes.where({ id: currentInstance }).first(function (ourSyncNode) { if (!ourSyncNode) { // We do not exist anymore. Call db.close() to teardown polls etc. if (db.isOpen()) db.close(); return; } ourSyncNode.lastHeartBeat = Date.now(); ourSyncNode.deleteTimeStamp = null; // Reset "deleteTimeStamp" flag if it was there. return db._syncNodes.put(ourSyncNode); }); }).catch('DatabaseClosedError', function () { // Ignore silently }).finally(function () { if (mySyncNode.node && mySyncNode.node.id === currentInstance && db.isOpen()) { heartbeatHandle = setTimeout(heartbeat, HEARTBEAT_INTERVAL); } }); } function poll() { pollHandle = null; var currentInstance = mySyncNode.node && mySyncNode.node.id; if (!currentInstance) return; Dexie.vip(function () { readChanges(Observable.latestRevision[db.name]).then(cleanup).then(consumeIntercommMessages) .catch('DatabaseClosedError', function () { // Handle database closed error gracefully while reading changes. // Don't trigger 'unhandledrejection'. // Even though we intercept the close() method, it might be called when in the middle of // reading changes and then that flow will cancel with DatabaseClosedError. }) .finally(function () { // Poll again in given interval: if (mySyncNode.node && mySyncNode.node.id === currentInstance && db.isOpen()) { pollHandle = setTimeout(poll, LOCAL_POLL); } }); }); } function cleanup() { var ourSyncNode = mySyncNode.node; if (!ourSyncNode) return Promise.reject(new Dexie.DatabaseClosedError()); return db.transaction('rw', '_syncNodes', '_changes', '_intercomm', function () { // Cleanup dead local nodes that has no heartbeat for over a minute // Dont do the following: //nodes.where("lastHeartBeat").below(Date.now() - NODE_TIMEOUT).and(function (node) { return node.type == "local"; }).delete(); // Because client may have been in hybernate mode and recently woken up. That would lead to deletion of all nodes. // Instead, we should mark any old nodes for deletion in a minute or so. If they still dont wakeup after that minute we could consider them dead. var weBecameMaster = false; db._syncNodes.where("lastHeartBeat").below(Date.now() - NODE_TIMEOUT).filter(function (node) { return node.type === 'local'; }).modify(function (node) { if (node.deleteTimeStamp && node.deleteTimeStamp < Date.now()) { // Delete the node. delete this.value; // Cleanup localStorage "deadnode:" entry for this node (localStorage API was used to wakeup other windows (onstorage event) - an event type missing in indexedDB.) if (localStorage) { localStorage.removeItem('Dexie.Observable/deadnode:' + node.id + '/' + db.name); } // Check if we are deleting a master node if (node.isMaster) { // The node we are deleting is master. We must take over that role. // OK to call nodes.update(). No need to call Dexie.vip() because nodes is opened in existing transaction! db._syncNodes.update(ourSyncNode, { isMaster: 1 }); weBecameMaster = true; } // Cleanup intercomm messages destinated to the node being deleted. // Those that waits for reply should be redirected to us. db._intercomm.where({ destinationNode: node.id }).modify(function (msg) { if (msg.wantReply) msg.destinationNode = ourSyncNode.id; else // Delete the message from DB and if someone is waiting for reply, let ourselved answer the request. delete this.value; }); } else if (!node.deleteTimeStamp) { // Mark the node for deletion node.deleteTimeStamp = Date.now() + HIBERNATE_GRACE_PERIOD; } }).then(function () { // Cleanup old revisions that no node is interested of. Observable.deleteOldChanges(db); return db.on("cleanup").fire(weBecameMaster); }); }); } function onBeforeUnload() { // Mark our own sync node for deletion. if (!mySyncNode.node) return; browserIsShuttingDown = true; mySyncNode.node.deleteTimeStamp = 1; // One millisecond after 1970. Makes it occur in the past but still keeps it truthy. mySyncNode.node.lastHeartBeat = 0; db._syncNodes.put(mySyncNode.node); // This async operation may be cancelled since the browser is closing down now. Observable.wereTheOneDying = true; // If other nodes in same window wakes up by this call, make sure they dont start taking over mastership and stuff... // Inform other windows that we're gone, so that they may take over our role if needed. Setting localStorage item below will trigger Observable.onStorage, which will trigger onSuicie() below: if (localStorage) { localStorage.setItem('Dexie.Observable/deadnode:' + mySyncNode.node.id.toString() + '/' + db.name, "dead"); // In IE, this will also wakeup our own window. However, that is doublechecked in nursecall subscriber below. } } function onSuicide(dbname, nodeID) { if (dbname === db.name && !Observable.wereTheOneDying) { // Make sure it's dead indeed. Second bullet. Why? Because it has marked itself for deletion in the onbeforeunload event, which is fired just before window dies. // It's own call to put() may have been cancelled. // Note also that in IE, this event may be called twice, but that doesnt harm! Dexie.vip(function () { db._syncNodes.update(nodeID, { deleteTimeStamp: 1, lastHeartBeat: 0 }).then(cleanup); }); } } } // // Static properties and methods // Observable.latestRevision = {}; // Latest revision PER DATABASE. Example: Observable.latestRevision.FriendsDB = 37; Observable.on = Dexie.Events(null, "latestRevisionIncremented", "suicideNurseCall", "intercomm", "beforeunload"); // fire(dbname, value); Observable.createUUID = createUUID; Observable.deleteOldChanges = deleteOldChanges; Observable._onStorage = initOnStorage(Observable); Observable._onBeforeUnload = function () { Observable.on.beforeunload.fire(); }; try { Observable.localStorageImpl = global.localStorage; } catch (ex) { } // // Map window events to static events in Dexie.Observable: // if (global.addEventListener) { global.addEventListener("storage", Observable._onStorage); global.addEventListener("beforeunload", Observable._onBeforeUnload); } // Register addon: Dexie.Observable = Observable; Dexie.addons.push(Observable); return Observable; }))); //# sourceMappingURL=dexie-observable.js.map