UNPKG

@windingtree/wt-js-libs

Version:

Javascript libraries to interact with the Winding Tree contracts

280 lines (261 loc) 10.1 kB
import cloneDeep from 'lodash.clonedeep'; import { RemoteDataAccessError, RemoteDataReadError } from './errors'; /** * Dataset ready to use various strategies for storing the data * in a remote storage. Every field backed by this strategy should * have a getter and setter defined that interacts with the * remote storage. The dataset strategy may be in the following states: * * - fresh - Purely in-memory created object with no data * - unsynced - Some data may be set locally, but they were not * propagated to the remote storage, and no data was loaded from the * remote storage. * - deployed - Data has its representation ready on the remote storage * and we may call remote getters and setters to interact with the remote storage. * - obsolete - Data lost its representation on the remote storage and * we should not interact with it anymore. * * Internally a state is held for each property. If you `get` a property * that was not previously accessed, the whole dataset gets synced (this might * get more efficient in the future), and you get a current value from * the remote storage. If any value was changed locally, it is considered * as the current value. Once you are done with data modification, you * have to call `updateRemoteData` that propagates the whole dataset to the * remote storage. These calls are deduplicated, so if a single call is used * to update multiple properties, only once call is done. */ export class RemotelyBackedDataset { /** * Generic factory method. */ static createInstance () { return new RemotelyBackedDataset(); } constructor () { this._obsoleteFlag = false; this._deployedFlag = false; this._localData = {}; this._remoteData = {}; this._fieldStates = {}; this._fieldKeys = []; this._syncing = null; } /** * Creates generic getters and setters that proxy `remoteGetter` and * `remoteSetter` when necessary. * * The fields are specified as an `options.fields` property and every key * represents a single property. Every property's options can than hold * a `remoteGetter` and `remoteSetter` field such as * * ``` * {fields: { * orgJsonUri: { * remoteGetter: async (): Promise<?string> => { * return (await this.contract.orgJsonUri().call(); * }, * // this will usually return a transaction ID * remoteSetter: async (): Promise<string> => { * return this.contract.methods.callHotel(this.address, data).send(txOptions); * } * }, * ``` * * All passed fields are set as unsynced which means that * after the first `get` on any of those, the whole dataset will * be synced from the remote storage (if the dataset is marked as deployed). * * @param {Object} options `{fields: {[field]: fieldOptions}}` * @param {Object} bindTo Object to which the properties will be bound. * Typically the initiator of this operation. */ bindProperties (options, bindTo) { this._options = options; this._fieldKeys = Object.keys(options.fields); for (let i = 0; i < this._fieldKeys.length; i++) { const fieldName = this._fieldKeys[i]; this._fieldStates[fieldName] = 'unsynced'; Object.defineProperty(bindTo, fieldName, { configurable: false, enumerable: true, get: async () => { return this._genericGetter(fieldName); }, set: (newValue) => { this._genericSetter(fieldName, newValue); }, }); } } /** * Is dataset marked as obsolete? * @return {Boolean} */ isObsolete () { return this._obsoleteFlag; } /** * Marks dataset as obsolete. Typically called after the remote storage * is destroyed or made inaccessible. This is not propagated anywhere * but merely serves as a flag to prevent further interaction with this object. */ markObsolete () { this._obsoleteFlag = true; } /** * Is dataset deployed to the remote storage? * @return {Boolean} */ isDeployed () { return this._deployedFlag; } /** * Marks dataset as deployed. Typically called when the remote * storage is set up, created or connected to. */ markDeployed () { this._deployedFlag = true; } /** * Tries to get a value. If the property was not synced before, * it will sync the whole dataset from a remote storage. If a property * was modified locally before, the modified value will be returned. * * @param {string} property * @throws {RemoteDataAccessError} When dataset is marked as obsolete * @return {any} property's current value */ async _genericGetter (property) { if (this.isObsolete()) { throw new RemoteDataAccessError('This object was destroyed in a remote storage!'); } // This is a totally new instance // TODO maybe don't init all at once, it might be expensive if (this.isDeployed() && this._fieldStates[property] === 'unsynced') { return this._syncRemoteData().then(() => { return this._localData[property]; }); } return this._localData[property]; } /** * Sets a new value locally and marks the property as dirty. Thath * means that even after syncing data from remote storage, the object will still * serve the locally modified value. * * @param {string} property * @param {any} newValue */ _genericSetter (property, newValue) { if (this.isObsolete()) { throw new RemoteDataAccessError('This object was destroyed in a remote storage!'); } // Write local value every time, even when we have nothing to compare it to if (this._localData[property] !== newValue || this._fieldStates[property] === 'unsynced') { this._localData[property] = newValue; this._fieldStates[property] = 'dirty'; } } async _fetchRemoteData () { if (!this.isDeployed()) { throw new RemoteDataAccessError('Cannot fetch undeployed object'); } const remoteGetters = []; for (let i = 0; i < this._fieldKeys.length; i++) { const remoteGetter = this._options.fields[this._fieldKeys[i]].remoteGetter; if (remoteGetter && this._fieldStates[this._fieldKeys[i]] === 'unsynced') { remoteGetters.push({ field: this._fieldKeys[i], fn: remoteGetter(), }); } } const remoteGetterFields = remoteGetters.map((x) => x.field); const remoteGetterFns = remoteGetters.map((x) => x.fn); if (remoteGetterFields.length) { const attributes = await (Promise.all(remoteGetterFns)); for (let i = 0; i < remoteGetterFields.length; i++) { this._remoteData[remoteGetterFields[i]] = attributes[i]; } } return this._remoteData; } async _syncRemoteData () { if (!this._syncing) { this._syncing = (async () => { try { await this._fetchRemoteData(); // Copy over data from remoteData to local data for (let i = 0; i < this._fieldKeys.length; i++) { // Do not update user-modified fields // TODO deal with 3rd party data modificiation on a remote storage if (this._remoteData[this._fieldKeys[i]] !== this._localData[this._fieldKeys[i]] && this._fieldStates[this._fieldKeys[i]] !== 'dirty') { this._localData[this._fieldKeys[i]] = this._remoteData[this._fieldKeys[i]]; this._fieldStates[this._fieldKeys[i]] = 'synced'; } } } catch (err) { throw new RemoteDataReadError('Cannot sync remote data: ' + err.message); } })(); } return this._syncing; } /** * Returns field's current state. * * @param {string} fieldName * @return {string|undefined} Can be 'dirty', 'synced', 'unsynced' */ getFieldState (fieldName) { return this._fieldStates[fieldName]; } // https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript-jquery _hashCode (text) { var hash = 0, i, chr; for (i = 0; i < text.length; i++) { chr = text.charCodeAt(i); hash = ((hash << 5) - hash) + chr; hash |= 0; // Convert to 32bit integer } return hash; } /** * Calls all remoteSetters if relevant data was changed. * Calls are deduplicated, so if the same method would be used * to update multiple fields, it is called only once. * * @param {Object} transactionOptions passed to every remoteSetter, typically something like `{from: address, to: address}` * @return {Array<any>} Results of remoteSetters, it would typically contain transaction metadata. In any case, an eventCallbacks * object is appended to every result and onReceipt callback is added to ensure that data fields would eventually be properly marked as 'synced'. */ async updateRemoteData (transactionOptions) { await this._syncRemoteData(); const remoteSetters = []; const remoteSettersHashCodes = {}; for (let i = 0; i < this._fieldKeys.length; i++) { const remoteSetter = this._options.fields[this._fieldKeys[i]].remoteSetter; if (remoteSetter && this._fieldStates[this._fieldKeys[i]] === 'dirty') { // deduplicate equal calls const setterHashCode = this._hashCode(remoteSetter.toString()); if (!remoteSettersHashCodes[setterHashCode]) { remoteSettersHashCodes[setterHashCode] = true; remoteSetters.push(remoteSetter(cloneDeep(transactionOptions)).then((result) => { result.eventCallbacks = result.eventCallbacks || {}; const originalOnRcptCallback = result.eventCallbacks.onReceipt; const onRcptCallback = (receipt) => { this._fieldStates[this._fieldKeys[i]] = 'synced'; if (originalOnRcptCallback) { return originalOnRcptCallback(receipt); } }; result.eventCallbacks.onReceipt = onRcptCallback; return result; })); } } } return Promise.all(remoteSetters); } } export default RemotelyBackedDataset;