UNPKG

firetruss

Version:

Advanced data sync layer for Firebase and Vue.js

425 lines (386 loc) 13.3 kB
import _ from 'lodash'; import Vue from 'vue'; import angular from './angularCompatibility.js'; import {splitPath} from './utils/paths.js'; class QueryHandler { constructor(coupler, query) { this._coupler = coupler; this._query = query; this._listeners = []; this._keys = []; this._url = this._coupler._rootUrl + query.path; this._segments = splitPath(query.path, true); this._listening = false; this.ready = false; } attach(operation, keysCallback) { this._listen(); this._listeners.push({operation, keysCallback}); if (keysCallback) keysCallback(this._keys); } detach(operation) { const k = _.findIndex(this._listeners, {operation}); if (k >= 0) this._listeners.splice(k, 1); return this._listeners.length; } _listen() { if (this._listening) return; this._coupler._bridge.on( this._query.toString(), this._url, this._query.constraints, 'value', this._handleSnapshot, this._handleError, this, {sync: true}); this._listening = true; } destroy() { this._coupler._bridge.off( this._query.toString(), this._url, this._query.constraints, 'value', this._handleSnapshot, this); this._listening = false; this.ready = false; angular.digest(); for (const key of this._keys) { this._coupler._decoupleSegments(this._segments.concat(key)); } } _handleSnapshot(snap) { this._coupler._queueSnapshotCallback(() => { // Order is important here: first couple any new subpaths so _handleSnapshot will update the // tree, then tell the client to update its keys, pulling values from the tree. if (!this._listeners.length || !this._listening) return; const updatedKeys = this._updateKeys(snap); this._coupler._applySnapshot(snap); if (!this.ready) { this.ready = true; angular.digest(); for (const listener of this._listeners) { this._coupler._dispatcher.markReady(listener.operation); } } if (updatedKeys) { for (const listener of this._listeners) { if (listener.keysCallback) listener.keysCallback(updatedKeys); } } }); } _updateKeys(snap) { let updatedKeys; if (snap.path === this._query.path) { updatedKeys = _.keys(snap.value); updatedKeys.sort(); if (_.isEqual(this._keys, updatedKeys)) { updatedKeys = null; } else { for (const key of _.difference(updatedKeys, this._keys)) { this._coupler._coupleSegments(this._segments.concat(key)); } for (const key of _.difference(this._keys, updatedKeys)) { this._coupler._decoupleSegments(this._segments.concat(key)); } this._keys = updatedKeys; } } else if (snap.path.replace(/\/[^/]+/, '') === this._query.path) { const hasKey = _.includes(this._keys, snap.key); if (snap.value) { if (!hasKey) { this._coupler._coupleSegments(this._segments.concat(snap.key)); this._keys.push(snap.key); this._keys.sort(); updatedKeys = this._keys; } } else if (hasKey) { this._coupler._decoupleSegments(this._segments.concat(snap.key)); _.pull(this._keys, snap.key); this._keys.sort(); updatedKeys = this._keys; } } return updatedKeys; } _handleError(error) { if (!this._listeners.length || !this._listening) return; this._listening = false; this.ready = false; angular.digest(); Promise.all(_.map(this._listeners, listener => { this._coupler._dispatcher.clearReady(listener.operation); return this._coupler._dispatcher.retry(listener.operation, error).catch(e => { listener.operation._disconnect(e); return false; }); })).then(results => { if (_.some(results)) { if (this._listeners.length) this._listen(); } else { for (const listener of this._listeners) listener.operation._disconnect(error); } }); } } class Node { constructor(coupler, path, parent) { this._coupler = coupler; this.path = path; this.parent = parent; this.url = this._coupler._rootUrl + path; this.operations = []; this.queryCount = 0; this.listening = false; this.ready = false; this.children = {}; } get active() { return this.count || this.queryCount; } get count() { return this.operations.length; } listen(skip) { if (!skip && this.count) { if (this.listening) return; _.forEach(this.operations, op => {this._coupler._dispatcher.clearReady(op);}); this._coupler._bridge.on( this.url, this.url, null, 'value', this._handleSnapshot, this._handleError, this, {sync: true}); this.listening = true; } else { _.forEach(this.children, child => {child.listen();}); } } unlisten(skip) { if (!skip && this.listening) { this._coupler._bridge.off(this.url, this.url, null, 'value', this._handleSnapshot, this); this.listening = false; this._forAllDescendants(node => { if (node.listening) return false; if (node.ready) { node.ready = false; angular.digest(); } }); } else { _.forEach(this.children, child => {child.unlisten();}); } } _handleSnapshot(snap) { this._coupler._queueSnapshotCallback(() => { if (!this.listening || !this._coupler.isTrunkCoupled(snap.path)) return; this._coupler._applySnapshot(snap); if (!this.ready && snap.path === this.path) { this.ready = true; angular.digest(); this.unlisten(true); this._forAllDescendants(node => { for (const op of node.operations) this._coupler._dispatcher.markReady(op); }); } }); } _handleError(error) { if (!this.count || !this.listening) return; this.listening = false; this._forAllDescendants(node => { if (node.listening) return false; if (node.ready) { node.ready = false; angular.digest(); } for (const op of node.operations) this._coupler._dispatcher.clearReady(op); }); return Promise.all(_.map(this.operations, op => { return this._coupler._dispatcher.retry(op, error).catch(e => { op._disconnect(e); return false; }); })).then(results => { if (_.some(results)) { if (this.count) this.listen(); } else { for (const op of this.operations) op._disconnect(error); // Pulling all the operations will automatically get us listening on descendants. } }); } _forAllDescendants(iteratee) { if (iteratee(this) === false) return; _.forEach(this.children, child => child._forAllDescendants(iteratee)); } collectCoupledDescendantPaths(paths) { if (!paths) paths = {}; paths[this.path] = this.active; if (!this.active) { _.forEach(this.children, child => {child.collectCoupledDescendantPaths(paths);}); } return paths; } } export default class Coupler { constructor(rootUrl, bridge, dispatcher, applySnapshot, prunePath) { this._rootUrl = rootUrl; this._bridge = bridge; this._dispatcher = dispatcher; this._applySnapshot = applySnapshot; this._pendingSnapshotCallbacks = []; this._throttled = {processPendingSnapshots: this._processPendingSnapshots}; this._prunePath = prunePath; this._vue = new Vue({data: {root: undefined, queryHandlers: {}}}); // Prevent Vue from instrumenting rendering since there's actually nothing to render, and the // warnings cause false positives from Lodash primitives when running tests. this._vue._renderProxy = this._vue; this._nodeIndex = Object.create(null); Object.freeze(this); // Set root node after freezing Coupler, otherwise it gets vue-ified too. this._vue.$data.root = new Node(this, '/'); this._nodeIndex['/'] = this._root; } get _root() { return this._vue.$data.root; } get _queryHandlers() { return this._vue.$data.queryHandlers; } destroy() { _.forEach(this._queryHandlers, queryHandler => {queryHandler.destroy();}); this._root.unlisten(); this._vue.$destroy(); } couple(path, operation) { return this._coupleSegments(splitPath(path, true), operation); } _coupleSegments(segments, operation) { let node; let superseded = !operation; let ready = false; for (const segment of segments) { let child = segment ? node.children && node.children[segment] : this._root; if (!child) { child = new Node(this, `${node.path === '/' ? '' : node.path}/${segment}`, node); Vue.set(node.children, segment, child); this._nodeIndex[child.path] = child; } superseded = superseded || child.listening; ready = ready || child.ready; node = child; } if (operation) { node.operations.push(operation); } else { node.queryCount++; } if (superseded) { if (operation && ready) this._dispatcher.markReady(operation); } else { node.listen(); // node will call unlisten() on descendants when ready } } decouple(path, operation) { return this._decoupleSegments(splitPath(path, true), operation); } _decoupleSegments(segments, operation) { const ancestors = []; let node; for (const segment of segments) { node = segment ? node.children && node.children[segment] : this._root; if (!node) break; ancestors.push(node); } if (!node || !(operation ? node.count : node.queryCount)) { throw new Error(`Path not coupled: ${segments.join('/') || '/'}`); } if (operation) { _.pull(node.operations, operation); } else { node.queryCount--; } if (operation && !node.count) { // Ideally, we wouldn't resync the full values here since we probably already have the current // value for all children. But making sure that's true is tricky in an async system (what if // the node's value changes and the update crosses the 'off' call in transit?) and this // situation should be sufficiently rare that the optimization is probably not worth it right // now. node.listen(); if (node.listening) node.unlisten(); } if (!node.active) { for (let i = ancestors.length - 1; i > 0; i--) { node = ancestors[i]; if (node === this._root || node.active || !_.isEmpty(node.children)) break; Vue.delete(ancestors[i - 1].children, segments[i]); node.ready = undefined; delete this._nodeIndex[node.path]; } const path = segments.join('/') || '/'; this._prunePath(path, this.findCoupledDescendantPaths(path)); } } subscribe(query, operation, keysCallback) { let queryHandler = this._queryHandlers[query.toString()]; if (!queryHandler) { queryHandler = new QueryHandler(this, query); Vue.set(this._queryHandlers, query.toString(), queryHandler); } queryHandler.attach(operation, keysCallback); } unsubscribe(query, operation) { const queryHandler = this._queryHandlers[query.toString()]; if (queryHandler && !queryHandler.detach(operation)) { queryHandler.destroy(); Vue.delete(this._queryHandlers, query.toString()); } } // Return whether the node at path or any ancestors are coupled. isTrunkCoupled(path) { const segments = splitPath(path, true); let node; for (const segment of segments) { node = segment ? node.children && node.children[segment] : this._root; if (!node) return false; if (node.active) return true; } return false; } findCoupledDescendantPaths(path) { let node; for (const segment of splitPath(path, true)) { node = segment ? node.children && node.children[segment] : this._root; if (node && node.active) return {[path]: node.active}; if (!node) break; } return node && node.collectCoupledDescendantPaths(); } isSubtreeReady(path) { let node, childSegment; function extractChildSegment(match) { childSegment = match.slice(1); return ''; } while (!(node = this._nodeIndex[path])) { path = path.replace(/\/[^/]*$/, extractChildSegment) || '/'; } if (childSegment) void node.children; // state an interest in the closest ancestor's children while (node) { if (node.ready) return true; node = node.parent; } return false; } isQueryReady(query) { const queryHandler = this._queryHandlers[query.toString()]; return queryHandler && queryHandler.ready; } _queueSnapshotCallback(callback) { this._pendingSnapshotCallbacks.push(callback); this._throttled.processPendingSnapshots.call(this); } _processPendingSnapshots() { for (const callback of this._pendingSnapshotCallbacks) callback(); // Property is frozen, so we need to splice to empty the array. this._pendingSnapshotCallbacks.splice(0, Infinity); } throttleSnapshots(delay) { if (delay) { this._throttled.processPendingSnapshots = _.throttle(this._processPendingSnapshots, delay); } else { this._throttled.processPendingSnapshots = this._processPendingSnapshots; } } }