UNPKG

@blockv/sdk

Version:

Allows web apps to display and interact with vatoms.

575 lines (487 loc) 17.7 kB
/* global localStorage */ import EventEmitter from './EventEmitter' import DataObject from './DataObject' import Filter from './Filter' import LZString from 'lz-string' import { merge, get, set } from 'lodash' import Delayer from './Delayer' /** * 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. */ export default class Region extends EventEmitter { /** @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.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.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 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.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.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.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(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.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 = get(object.data, keyPath) // Notify this.willUpdateField(object, keyPath, oldValue, value) // Update to new value 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 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(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) {} }