UNPKG

@blockv/sdk

Version:

Allows web apps to display and interact with vatoms.

648 lines (480 loc) 17.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _EventEmitter = _interopRequireDefault(require("./EventEmitter")); var _DataObject = _interopRequireDefault(require("./DataObject")); var _Filter = _interopRequireDefault(require("./Filter")); var _lzString = _interopRequireDefault(require("lz-string")); var _lodash = require("lodash"); var _Delayer = _interopRequireDefault(require("./Delayer")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /* global localStorage */ /** * Base class for a region. * * @event updated When any data in the region changes. This also indicates that there is no longer an error. * @event object.updated When a data object changes. Called with the ID of the changed object. * @event object.removed When a data object is removed. Called with the ID of the removed object. * @event error When an error occurs. */ class Region extends _EventEmitter.default { /** @private Subclasses should use this to update and start monitoring the region */ constructor(dataPool) { super(); /** If true, this region will not be cached to disk. */ this.noCache = false; /** Store reference to the data pool */ this.dataPool = dataPool; /** All current data objects */ this.objects = new Map(); /** True if data in this region is entirely in sync with the backend */ this.synchronized = false; /** If there's an error, this contains the current error. */ this.error = null; // Try to make region stable immediately this._syncPromise = null; _Delayer.default.run(e => this.synchronize()); } /** * Re-synchronizes the region by manually fetching everything from the server again. */ forceSynchronize() { this.synchronized = false; return this.synchronize(); } /** * This will try to make the region stable by querying the backend for all data. * * @private Called by the Region superclass. * @returns {Promise} Resolves once the region is in sync with the backend. */ synchronize() { // Stop if already running if (this._syncPromise) { return this._syncPromise; } // Remove pending error this.error = null; this.emit('updated'); // Stop if already in sync if (this.synchronized) { return Promise.resolve(); } // Call load console.log(`[DataPool > Region] Starting synchronization for region ${this.stateKey}`); this._syncPromise = this.load().then(loadedIDs => { // If the subclass load() returned an array of IDs, we can remove everything which is not in that list. if (loadedIDs && typeof loadedIDs.length === 'number') { let keysToRemove = []; for (let id of this.objects.keys()) { // Check if it's in our list if (!loadedIDs.includes(id)) { keysToRemove.push(id); } } // Remove vatoms this.removeObjects(keysToRemove); } // All data is up to date! this.synchronized = true; this._syncPromise = null; console.log(`[DataPool > Region] Region '${this.stateKey}' is now in sync!`); }).catch(err => { // Error handling, notify listeners of an error this._syncPromise = null; this.error = err; console.error(err); this.emit('error', err); }); // Return promise return this._syncPromise; } /** * A key which is unique for this exact region. This is used when saving/restoring state to disk. * * @abstract Subclasses should override this. * @returns {String} The state key. */ get stateKey() { throw new Error(`Subclasses must override 'get stateKey()' in order to correctly handle saving/restoring state to disk.`); } /** * Start initial load. This should resolve once the region is up to date. * * @private Called by the Region superclass. * @abstract Subclasses should override this. * @returns {Promise<>} Once this promise resolves, the region should be stable. */ async load() { throw new Error(`Subclasses must override Region.load()`); } /** * Stop and destroy this region. * * @abstract Subclasses should override this, but call super.close() */ close() { // Notify data pool we have closed this.dataPool.removeRegion(this); } /** * Checks if the specified query matches our region. This is used to identify if a region request * can be satisfied by this region, or if a new region should be created. * * @private Called by DataPool. * @abstract Subclasses should override this. * @param {string} id The region plugin ID * @param {*} descriptor The region-specific filter data. */ matches(id, descriptor) { throw new Error('Subclasses must override Region.matches()'); } /** * Stores a collection of data objects which have been added to the pool. * * @private Called by subclasses. * @param {DataObject[]} objects List of new data objects added to the pool. */ addObjects(objects) { // Go through each object for (let obj of objects) { // Check if object exists already let existingObject = this.objects.get(obj.id); if (existingObject) { // Notify this.willUpdateFields(existingObject, obj.data); // It exists already, update the object existingObject.data = obj.data; existingObject.cached = null; } else { // Notify this.willAdd(obj); // It does not exist, add it this.objects.set(obj.id, obj); } // Emit event, on next run loop so all objects are added first _Delayer.default.run(e => this.emit('object.updated', obj.id)); } // Notify updated if (objects.length > 0) { this.emit('updated'); } // Save soon if (objects.length > 0) { this.save(); } } /** * Updates data objects within our pool. * * @private Called by subclasses * @param {Object[]} objects An array of changes. Each object contains an `id` string and a `new_data` sparse object containing the changed fields. */ updateObjects(objects) { // Go through each object let didUpdate = false; for (let obj of objects) { // Fetch existing object let existingObject = this.objects.get(obj.id); if (!existingObject) { continue; } // Stop if existing object doesn't have the full data if (!existingObject.data) { continue; } // Notify this.willUpdateFields(existingObject, obj.new_data); // Update fields (0, _lodash.merge)(existingObject.data, obj.new_data); // Clear cached values existingObject.cached = null; // Emit event, on next run loop so all objects are updated first _Delayer.default.run(e => this.emit('object.updated', obj.id)); didUpdate = true; } // Notify updated if (didUpdate) { this.emit('updated'); } // Save soon if (didUpdate) { this.save(); } } /** * Removes the specified objects from our pool. * * @private Called by subclasses. * @param {String[]} ids An array of object IDs to remove. */ removeObjects(ids) { // Remove all data objects with the specified IDs let didUpdate = false; for (let id of ids) { // Notify this.willRemove(id); // Remove it if (this.objects.delete(id)) { // Emit event, on next run loop so all objects are updated first _Delayer.default.run(e => this.emit('object.removed', id)); didUpdate = true; } } // Notify updated if (didUpdate) { this.emit('updated'); } // Save soon if (didUpdate) { this.save(); } } /** * If a region plugin depends on the session data, it may override this method and `this.close()` itself if needed. * * @private Called by DataPool. * @abstract Subclasses can override this if they want. * @param {*} info The new session info. */ onSessionInfoChanged(info) {} /** * If the plugin wants, it can map DataObjects to another type. This takes in a DataObject and returns a new type. * If you return null, the specified data object will not be returned. * * The default implementation simply returns the DataObject. * * @param {DataObject} object The input raw object * @returns {*} The output object. */ map(object) { return object; } /** * Iterate over each object in this region. Return `false` from the callback to stop. This does not wait * for the region to synchronize. This is a synchronous function. * * @param {Function(*)} callback Gets called once for each objbect in the region. */ forEach(callback) { // Go through all data objects for (let object of this.objects.values()) { // Check for cached object let mapped = object.cached; // Check if no cached object if (!mapped) { // Map to the plugin's intended type object.cached = mapped = this.map(object); } // Stop if no mapped object if (!mapped) { continue; } // Call callback, stop if they returned false if (callback(mapped) === false) { break; } } } /** * Returns all the objects within this region. * * @param {Boolean} waitUntilStable If true, will wait until all data objects have been retrieved. If false, will return immediately with current data. * @returns {Promise<Object[]>} An array of objects in this region. If `waitUntilStable` is false, returns the array immediately (without the promise). */ get(waitUntilStable = true) { // Synchronize now if (waitUntilStable) { return this.synchronize().then(e => this.get(false)); } // Create an array of all data objects let items = []; for (let object of this.objects.values()) { // Check for cached object if (object.cached) { items.push(object.cached); continue; } // Map to the plugin's intended type let mapped = this.map(object); if (!mapped) { continue; } // Cache it object.cached = mapped; // Add to list items.push(mapped); } // Done return items; } /** * Returns an object within this region by it's ID. * * @param {Boolean} waitUntilStable If true, will wait until all data objects have been retrieved. If false, will return immediately with current data. * @returns {Promise<Object>} An object in this region. If `waitUntilStable` is false, returns immediately (without the promise). */ getItem(id, waitUntilStable = true) { // Synchronize now if (waitUntilStable) { return this.synchronize().then(e => this.getItem(id, false)); } // Get object let object = this.objects.get(id); if (!object) { return null; } // Check for cached object if (object.cached) { return object.cached; } // Map to the plugin's intended type let mapped = this.map(object); if (!mapped) { return null; } // Cache it object.cached = mapped; // Done return mapped; } /** * Returns true if the object with the specified ID exists in the cache. * * @param {*} id The object's ID * @returns {boolean} True if the object exists. */ has(id) { return this.objects.has(id); } /** Load objects from local storage */ loadFromCache() { // Skip if not allowed if (this.noCache) { return; } // Load local cached objects try { // Fetch JSON from disk let startTime = Date.now(); let compressed = localStorage['datapool.cache.' + this.stateKey]; let txt = ''; // Stop if no data if (!compressed) { return; } // Decompress it if needed if (compressed.substring(0, 1) === 'c') { txt = _lzString.default.decompress(compressed.substring(1)); } else if (compressed.substring(0, 1) === 'u') { txt = compressed.substring(1); } else { txt = '[]'; } // Decode text let json = JSON.parse(txt); // Add objects let newObjects = json.map(i => new _DataObject.default(i[0], i[1], i[2])); this.addObjects(newObjects); // Done console.debug(`[DataPool > Region] Loaded cached object pool, ${Math.floor(txt.length / 1000)} KB (${Math.floor(compressed.length / 1000)} KB compressed) in ${Date.now() - startTime} ms`); } catch (err) { console.warn(`[DataPool > Region] Unable to recover locally cached objects`, err); } } /** * Saves the region to local storage. * * @private Called by the Region superclass. */ save() { // Skip if not allowed if (this.noCache) { return; } // Clear previous pending save request if (this.saveTimer) { clearTimeout(this.saveTimer); } // Create save timer this.saveTimer = setTimeout(e => { // Remove save timer this.saveTimer = null; try { // Save all items to local storage let startTime = Date.now(); let txt = JSON.stringify(Array.from(this.objects.values()).map(o => [o.type, o.id, o.data])); // Try save uncompressed try { // Save localStorage['datapool.cache.' + this.stateKey] = 'u' + txt; // Done console.debug(`[DataPool > Region] Saved to local cache, size is ${Math.floor(txt.length / 1000)} KB, saved in ${Date.now() - startTime} ms`); } catch (noSpaceError) { // Compress and store text let compressed = _lzString.default.compress(txt); localStorage['datapool.cache.' + this.stateKey] = 'c' + compressed; // Done console.debug(`[DataPool > Region] Saved to local cache, size is ${Math.floor(txt.length / 1000)} KB, ${Math.floor(compressed.length / 1000)} KB compressed, saved in ${Date.now() - startTime} ms`); } } catch (err) { // Error console.warn(`[DataPool > Region] Unable to save local cache`, err); } }, 15000); } /** * Change a field, and return a function which can be called to undo the change. * * @param {String} id Object ID * @param {String} keyPath The key to change * @param {*} value The new value * @returns {Function} An undo function */ preemptiveChange(id, keyPath, value) { // Get object. If it doesn't exist, do nothing and return an undo function which does nothing. let object = this.objects.get(id); if (!object) { return function () {}; } // Get current value let oldValue = (0, _lodash.get)(object.data, keyPath); // Notify this.willUpdateField(object, keyPath, oldValue, value); // Update to new value (0, _lodash.set)(object.data, keyPath, value); this.emit('object.updated', id); this.emit('updated'); // Return undo function return e => { // Notify this.willUpdateField(object, keyPath, value, oldValue); // Revert (0, _lodash.set)(object.data, keyPath, oldValue); this.emit('object.updated', id); this.emit('updated'); }; } /** * Remove an object, and return an undo function. * * @param {String} id The ID of the object to remove. * @returns {Function} An undo function */ preemptiveRemove(id) { // Get object. If it doesn't exist, do nothing and return an undo function which does nothing. let object = this.objects.get(id); if (!object) { return function () {}; } // Notify this.willRemove(object); // Remove object this.objects.delete(id); this.emit('updated'); // Return undo function return e => { // Check that a new object wasn't added in the mean time if (this.objects.has(id)) { return; } // Notify this.willAdd(object); // Revert this.addObjects([object]); }; } /** * Create a filter * * @param {String} keyPath The data path to check * @param {*} value The value to check for * @returns {Filter} The filtered region */ filter(keyPath, value) { return new _Filter.default(this, keyPath, value); } /** * Called when an object is about to be added. * * @private * @abstract Can be overridden by subclasses which need to get these events. * @param {DataObject} object The object which will be added. */ willAdd(object) {} /** * Called when an object is about to be updated. * * @private * @abstract Can be overridden by subclasses which need to get these events. * @param {DataObject} object The object which will be updated. * @param {Object} newData The sparse object containing the changed fields */ willUpdateFields(object, newData) {} /** * Called when an object is about to be updated. * * @private * @abstract Can be overridden by subclasses which need to get these events. * @param {DataObject} object The object which will be updated. * @param {String} keyPath The field which will be changed. * @param {*} oldValue The current field value. * @param {*} newValue The new field value. */ willUpdateField(object, keyPath, oldValue, newValue) {} /** * Called when an object is about to be removed. * * @private * @abstract Can be overridden by subclasses which need to get these events. * @param {DataObject|String} objectOrID The object (or ID) which will be updated. */ willRemove(objectOrID) {} } exports.default = Region;