UNPKG

14.3 kBJavaScriptView Raw
1import _ from 'lodash';
2import Vue from 'vue';
3import angular from './angularCompatibility.js';
4import {splitPath} from './utils/paths.js';
5
6
7class QueryHandler {
8 constructor(coupler, query) {
9 this._coupler = coupler;
10 this._query = query;
11 this._listeners = [];
12 this._keys = [];
13 this._url = this._coupler._rootUrl + query.path;
14 this._segments = splitPath(query.path, true);
15 this._listening = false;
16 this.ready = false;
17 }
18
19 attach(operation, keysCallback) {
20 this._listen();
21 this._listeners.push({operation, keysCallback});
22 if (this.ready) {
23 this._coupler._dispatcher.markReady(operation);
24 if (keysCallback) keysCallback(this._keys);
25 }
26 }
27
28 detach(operation) {
29 const k = _.findIndex(this._listeners, {operation});
30 if (k >= 0) this._listeners.splice(k, 1);
31 return this._listeners.length;
32 }
33
34 _listen() {
35 if (this._listening) return;
36 this._coupler._bridge.on(
37 this._query.toString(), this._url, this._query.constraints, 'value',
38 this._handleSnapshot, this._handleError, this, {sync: true});
39 this._listening = true;
40 }
41
42 destroy() {
43 this._coupler._bridge.off(
44 this._query.toString(), this._url, this._query.constraints, 'value', this._handleSnapshot,
45 this);
46 this._listening = false;
47 this.ready = false;
48 angular.digest();
49 for (const key of this._keys) {
50 this._coupler._decoupleSegments(this._segments.concat(key));
51 }
52 }
53
54 _handleSnapshot(snap) {
55 this._coupler._queueSnapshotCallback(() => {
56 // Order is important here: first couple any new subpaths so _handleSnapshot will update the
57 // tree, then tell the client to update its keys, pulling values from the tree.
58 if (!this._listeners.length || !this._listening) return;
59 const updatedKeys = this._updateKeysAndApplySnapshot(snap);
60 if (!this.ready) {
61 this.ready = true;
62 angular.digest();
63 for (const listener of this._listeners) {
64 this._coupler._dispatcher.markReady(listener.operation);
65 }
66 }
67 if (updatedKeys) {
68 for (const listener of this._listeners) {
69 if (listener.keysCallback) listener.keysCallback(updatedKeys);
70 }
71 }
72 });
73 }
74
75 _updateKeysAndApplySnapshot(snap) {
76 let updatedKeys;
77 if (snap.path === this._query.path) {
78 updatedKeys = _.keys(snap.value);
79 updatedKeys.sort();
80 if (_.isEqual(this._keys, updatedKeys)) {
81 updatedKeys = null;
82 } else {
83 for (const key of _.difference(updatedKeys, this._keys)) {
84 this._coupler._coupleSegments(this._segments.concat(key));
85 }
86 for (const key of _.difference(this._keys, updatedKeys)) {
87 // Decoupling a segment will prune the tree at that location is there are no other
88 // listeners.
89 this._coupler._decoupleSegments(this._segments.concat(key));
90 }
91 this._keys = updatedKeys;
92 }
93 // The snapshot may be partial, so create synthetic snapshots for subpaths and apply those to
94 // update / insert values. (Deleted ones got pruned above.)
95 if (snap.exists) {
96 const rootValue = snap.value;
97 const rootPath = snap.path;
98 for (const key of this._keys) {
99 snap._path = rootPath + '/' + key;
100 snap._key = undefined;
101 snap._value = rootValue[key];
102 this._coupler._applySnapshot(snap);
103 }
104 // Restore original properties, just in case.
105 snap._path = rootPath;
106 snap._key = undefined;
107 snap._value = rootValue;
108 }
109 } else if (snap.path.replace(/\/[^/]+/, '') === this._query.path) {
110 const hasKey = _.includes(this._keys, snap.key);
111 if (snap.value) {
112 if (!hasKey) {
113 this._coupler._coupleSegments(this._segments.concat(snap.key));
114 this._keys.push(snap.key);
115 this._keys.sort();
116 updatedKeys = this._keys;
117 }
118 } else if (hasKey) {
119 this._coupler._decoupleSegments(this._segments.concat(snap.key));
120 _.pull(this._keys, snap.key);
121 this._keys.sort();
122 updatedKeys = this._keys;
123 }
124 // A snapshot under the query's level is guaranteed to be a full snapshot, so we can apply it
125 // directly.
126 this._coupler._applySnapshot(snap);
127 }
128 return updatedKeys;
129 }
130
131 _handleError(error) {
132 if (!this._listeners.length || !this._listening) return;
133 this._listening = false;
134 this.ready = false;
135 angular.digest();
136 Promise.all(_.map(this._listeners, listener => {
137 this._coupler._dispatcher.clearReady(listener.operation);
138 return this._coupler._dispatcher.retry(listener.operation, error).catch(e => {
139 listener.operation._disconnect(e);
140 return false;
141 });
142 })).then(results => {
143 if (_.some(results)) {
144 if (this._listeners.length) this._listen();
145 } else {
146 for (const listener of this._listeners) listener.operation._disconnect(error);
147 }
148 });
149 }
150}
151
152
153class Node {
154 constructor(coupler, path, parent) {
155 this._coupler = coupler;
156 this.path = path;
157 this.parent = parent;
158 this.url = this._coupler._rootUrl + path;
159 this.operations = [];
160 this.queryCount = 0;
161 this.listening = false;
162 this.ready = false;
163 this.children = {};
164 }
165
166 get active() {
167 return this.count || this.queryCount;
168 }
169
170 get count() {
171 return this.operations.length;
172 }
173
174 listen(skip) {
175 if (!skip && this.count) {
176 if (this.listening) return;
177 _.forEach(this.operations, op => {this._coupler._dispatcher.clearReady(op);});
178 this._coupler._bridge.on(
179 this.url, this.url, null, 'value', this._handleSnapshot, this._handleError, this,
180 {sync: true});
181 this.listening = true;
182 } else {
183 _.forEach(this.children, child => {child.listen();});
184 }
185 }
186
187 unlisten(skip) {
188 if (!skip && this.listening) {
189 this._coupler._bridge.off(this.url, this.url, null, 'value', this._handleSnapshot, this);
190 this.listening = false;
191 this._forAllDescendants(node => {
192 if (node.listening) return false;
193 if (node.ready) {
194 node.ready = false;
195 angular.digest();
196 }
197 });
198 } else {
199 _.forEach(this.children, child => {child.unlisten();});
200 }
201 }
202
203 _handleSnapshot(snap) {
204 this._coupler._queueSnapshotCallback(() => {
205 if (!this.listening || !this._coupler.isTrunkCoupled(snap.path)) return;
206 this._coupler._applySnapshot(snap);
207 if (!this.ready && snap.path === this.path) {
208 this.ready = true;
209 angular.digest();
210 this.unlisten(true);
211 this._forAllDescendants(node => {
212 for (const op of node.operations) this._coupler._dispatcher.markReady(op);
213 });
214 }
215 });
216 }
217
218 _handleError(error) {
219 if (!this.count || !this.listening) return;
220 this.listening = false;
221 this._forAllDescendants(node => {
222 if (node.listening) return false;
223 if (node.ready) {
224 node.ready = false;
225 angular.digest();
226 }
227 for (const op of node.operations) this._coupler._dispatcher.clearReady(op);
228 });
229 return Promise.all(_.map(this.operations, op => {
230 return this._coupler._dispatcher.retry(op, error).catch(e => {
231 op._disconnect(e);
232 return false;
233 });
234 })).then(results => {
235 if (_.some(results)) {
236 if (this.count) this.listen();
237 } else {
238 for (const op of this.operations) op._disconnect(error);
239 // Pulling all the operations will automatically get us listening on descendants.
240 }
241 });
242 }
243
244 _forAllDescendants(iteratee) {
245 if (iteratee(this) === false) return;
246 _.forEach(this.children, child => child._forAllDescendants(iteratee));
247 }
248
249 collectCoupledDescendantPaths(paths) {
250 if (!paths) paths = {};
251 paths[this.path] = this.active;
252 if (!this.active) {
253 _.forEach(this.children, child => {child.collectCoupledDescendantPaths(paths);});
254 }
255 return paths;
256 }
257}
258
259
260export default class Coupler {
261 constructor(rootUrl, bridge, dispatcher, applySnapshot, prunePath) {
262 this._rootUrl = rootUrl;
263 this._bridge = bridge;
264 this._dispatcher = dispatcher;
265 this._applySnapshot = applySnapshot;
266 this._pendingSnapshotCallbacks = [];
267 this._throttled = {processPendingSnapshots: this._processPendingSnapshots};
268 this._prunePath = prunePath;
269 this._vue = new Vue({data: {root: undefined, queryHandlers: {}}});
270 // Prevent Vue from instrumenting rendering since there's actually nothing to render, and the
271 // warnings cause false positives from Lodash primitives when running tests.
272 this._vue._renderProxy = this._vue;
273 this._nodeIndex = Object.create(null);
274 Object.freeze(this);
275 // Set root node after freezing Coupler, otherwise it gets vue-ified too.
276 this._vue.$data.root = new Node(this, '/');
277 this._nodeIndex['/'] = this._root;
278 }
279
280 get _root() {
281 return this._vue.$data.root;
282 }
283
284 get _queryHandlers() {
285 return this._vue.$data.queryHandlers;
286 }
287
288 destroy() {
289 _.forEach(this._queryHandlers, queryHandler => {queryHandler.destroy();});
290 this._root.unlisten();
291 this._vue.$destroy();
292 }
293
294 couple(path, operation) {
295 return this._coupleSegments(splitPath(path, true), operation);
296 }
297
298 _coupleSegments(segments, operation) {
299 let node;
300 let superseded = !operation;
301 let ready = false;
302 for (const segment of segments) {
303 let child = segment ? node.children && node.children[segment] : this._root;
304 if (!child) {
305 child = new Node(this, `${node.path === '/' ? '' : node.path}/${segment}`, node);
306 Vue.set(node.children, segment, child);
307 this._nodeIndex[child.path] = child;
308 }
309 superseded = superseded || child.listening;
310 ready = ready || child.ready;
311 node = child;
312 }
313 if (operation) {
314 node.operations.push(operation);
315 } else {
316 node.queryCount++;
317 }
318 if (superseded) {
319 if (operation && ready) this._dispatcher.markReady(operation);
320 } else {
321 node.listen(); // node will call unlisten() on descendants when ready
322 }
323 }
324
325 decouple(path, operation) {
326 return this._decoupleSegments(splitPath(path, true), operation);
327 }
328
329 _decoupleSegments(segments, operation) {
330 const ancestors = [];
331 let node;
332 for (const segment of segments) {
333 node = segment ? node.children && node.children[segment] : this._root;
334 if (!node) break;
335 ancestors.push(node);
336 }
337 if (!node || !(operation ? node.count : node.queryCount)) {
338 throw new Error(`Path not coupled: ${segments.join('/') || '/'}`);
339 }
340 if (operation) {
341 _.pull(node.operations, operation);
342 } else {
343 node.queryCount--;
344 }
345 if (operation && !node.count) {
346 // Ideally, we wouldn't resync the full values here since we probably already have the current
347 // value for all children. But making sure that's true is tricky in an async system (what if
348 // the node's value changes and the update crosses the 'off' call in transit?) and this
349 // situation should be sufficiently rare that the optimization is probably not worth it right
350 // now.
351 node.listen();
352 if (node.listening) node.unlisten();
353 }
354 if (!node.active) {
355 for (let i = ancestors.length - 1; i > 0; i--) {
356 node = ancestors[i];
357 if (node === this._root || node.active || !_.isEmpty(node.children)) break;
358 Vue.delete(ancestors[i - 1].children, segments[i]);
359 node.ready = undefined;
360 delete this._nodeIndex[node.path];
361 }
362 const path = segments.join('/') || '/';
363 this._prunePath(path, this.findCoupledDescendantPaths(path));
364 }
365 }
366
367 subscribe(query, operation, keysCallback) {
368 let queryHandler = this._queryHandlers[query.toString()];
369 if (!queryHandler) {
370 queryHandler = new QueryHandler(this, query);
371 Vue.set(this._queryHandlers, query.toString(), queryHandler);
372 }
373 queryHandler.attach(operation, keysCallback);
374 }
375
376 unsubscribe(query, operation) {
377 const queryHandler = this._queryHandlers[query.toString()];
378 if (queryHandler && !queryHandler.detach(operation)) {
379 queryHandler.destroy();
380 Vue.delete(this._queryHandlers, query.toString());
381 }
382 }
383
384 // Return whether the node at path or any ancestors are coupled.
385 isTrunkCoupled(path) {
386 const segments = splitPath(path, true);
387 let node;
388 for (const segment of segments) {
389 node = segment ? node.children && node.children[segment] : this._root;
390 if (!node) return false;
391 if (node.active) return true;
392 }
393 return false;
394 }
395
396 findCoupledDescendantPaths(path) {
397 let node;
398 for (const segment of splitPath(path, true)) {
399 node = segment ? node.children && node.children[segment] : this._root;
400 if (node && node.active) return {[path]: node.active};
401 if (!node) break;
402 }
403 return node && node.collectCoupledDescendantPaths();
404 }
405
406 isSubtreeReady(path) {
407 let node, childSegment;
408 function extractChildSegment(match) {
409 childSegment = match.slice(1);
410 return '';
411 }
412 while (!(node = this._nodeIndex[path])) {
413 path = path.replace(/\/[^/]*$/, extractChildSegment) || '/';
414 }
415 if (childSegment) void node.children; // state an interest in the closest ancestor's children
416 while (node) {
417 if (node.ready) return true;
418 node = node.parent;
419 }
420 return false;
421 }
422
423 isQueryReady(query) {
424 const queryHandler = this._queryHandlers[query.toString()];
425 return queryHandler && queryHandler.ready;
426 }
427
428 _queueSnapshotCallback(callback) {
429 this._pendingSnapshotCallbacks.push(callback);
430 this._throttled.processPendingSnapshots.call(this);
431 }
432
433 _processPendingSnapshots() {
434 for (const callback of this._pendingSnapshotCallbacks) callback();
435 // Property is frozen, so we need to splice to empty the array.
436 this._pendingSnapshotCallbacks.splice(0, Infinity);
437 }
438
439 throttleSnapshots(delay) {
440 if (delay) {
441 this._throttled.processPendingSnapshots =
442 _.debounce(_.throttle(this._processPendingSnapshots, delay));
443 } else {
444 this._throttled.processPendingSnapshots = this._processPendingSnapshots;
445 }
446 }
447}
448