UNPKG

27.7 kBJavaScriptView Raw
1import angular from './angularCompatibility.js';
2import Coupler from './Coupler.js';
3import Modeler from './Modeler.js';
4import Reference from './Reference.js';
5import {escapeKey, escapeKeys, unescapeKey, joinPath, splitPath} from './utils/paths.js';
6import {wrapPromiseCallback, promiseFinally} from './utils/promises.js';
7import {SERVER_TIMESTAMP} from './utils/utils.js';
8
9import _ from 'lodash';
10import Vue from 'vue';
11
12
13class Transaction {
14 constructor(ref) {
15 this._ref = ref;
16 this._outcome = undefined;
17 this._values = undefined;
18 }
19
20 get currentValue() {return this._ref.value;}
21 get outcome() {return this._outcome;}
22 get values() {return this._values;}
23
24 _setOutcome(value) {
25 if (this._outcome) throw new Error('Transaction already resolved with ' + this._outcome);
26 this._outcome = value;
27 }
28
29 abort() {
30 this._setOutcome('abort');
31 }
32
33 cancel() {
34 this._setOutcome('cancel');
35 }
36
37 set(value) {
38 if (value === undefined) throw new Error('Invalid argument: undefined');
39 this._setOutcome('set');
40 this._values = {'': value};
41 }
42
43 update(values) {
44 if (values === undefined) throw new Error('Invalid argument: undefined');
45 if (_.isEmpty(values)) return this.cancel();
46 this._setOutcome('update');
47 this._values = values;
48 }
49}
50
51
52export default class Tree {
53 constructor(truss, rootUrl, bridge, dispatcher) {
54 this._truss = truss;
55 this._rootUrl = rootUrl;
56 this._bridge = bridge;
57 this._dispatcher = dispatcher;
58 this._firebasePropertyEditAllowed = false;
59 this._writeSerial = 0;
60 this._localWrites = {};
61 this._localWriteTimestamp = null;
62 this._initialized = false;
63 this._modeler = new Modeler(truss.constructor.VERSION === 'dev');
64 this._coupler = new Coupler(
65 rootUrl, bridge, dispatcher, this._integrateSnapshot.bind(this), this._prune.bind(this));
66 this._vue = new Vue({data: {$root: undefined}});
67 Object.seal(this);
68 // Call this.init(classes) to complete initialization; we need two phases so that truss can bind
69 // the tree into its own accessors prior to defining computed functions, which may try to
70 // access the tree root via truss.
71 }
72
73 get root() {
74 if (!this._vue.$data.$root) {
75 this._vue.$data.$root = this._createObject('/');
76 this._fixObject(this._vue.$data.$root);
77 this._completeCreateObject(this._vue.$data.$root);
78 angular.digest();
79 }
80 return this._vue.$data.$root;
81 }
82
83 get truss() {
84 return this._truss;
85 }
86
87 init(classes) {
88 if (this._initialized) {
89 throw new Error('Data objects already created, too late to mount classes');
90 }
91 this._initialized = true;
92 this._modeler.init(classes, !this._vue.$data.$root);
93 const createdObjects = [];
94 this._plantPlaceholders(this.root, '/', undefined, createdObjects);
95 for (const object of createdObjects) this._completeCreateObject(object);
96 }
97
98 destroy() {
99 this._coupler.destroy();
100 if (this._modeler) this._modeler.destroy();
101 this._vue.$destroy();
102 }
103
104 connectReference(ref, method) {
105 this._checkHandle(ref);
106 const operation = this._dispatcher.createOperation('read', method, ref);
107 let unwatch;
108 operation._disconnect = this._disconnectReference.bind(this, ref, operation, unwatch);
109 this._dispatcher.begin(operation).then(() => {
110 if (operation.running && !operation._disconnected) {
111 this._coupler.couple(ref.path, operation);
112 operation._coupled = true;
113 }
114 }).catch(_.noop); // ignore exception, let onFailure handlers deal with it
115 return operation._disconnect;
116 }
117
118 _disconnectReference(ref, operation, unwatch, error) {
119 if (operation._disconnected) return;
120 operation._disconnected = true;
121 if (unwatch) unwatch();
122 if (operation._coupled) {
123 this._coupler.decouple(ref.path, operation); // will call back to _prune if necessary
124 operation._coupled = false;
125 }
126 this._dispatcher.end(operation, error).catch(_.noop);
127 }
128
129 isReferenceReady(ref) {
130 this._checkHandle(ref);
131 return this._coupler.isSubtreeReady(ref.path);
132 }
133
134 connectQuery(query, keysCallback, method) {
135 this._checkHandle(query);
136 const operation = this._dispatcher.createOperation('read', method, query);
137 operation._disconnect = this._disconnectQuery.bind(this, query, operation);
138 this._dispatcher.begin(operation).then(() => {
139 if (operation.running && !operation._disconnected) {
140 this._coupler.subscribe(query, operation, keysCallback);
141 operation._coupled = true;
142 }
143 }).catch(_.noop); // ignore exception, let onFailure handlers deal with it
144 return operation._disconnect;
145 }
146
147 _disconnectQuery(query, operation, error) {
148 if (operation._disconnected) return;
149 operation._disconnected = true;
150 if (operation._coupled) {
151 this._coupler.unsubscribe(query, operation); // will call back to _prune if necessary
152 operation._coupled = false;
153 }
154 this._dispatcher.end(operation, error).catch(_.noop);
155 }
156
157 isQueryReady(query) {
158 return this._coupler.isQueryReady(query);
159 }
160
161 _checkHandle(handle) {
162 if (!handle.belongsTo(this._truss)) {
163 throw new Error('Reference belongs to another Truss instance');
164 }
165 }
166
167 throttleRemoteDataUpdates(delay) {
168 this._coupler.throttleSnapshots(delay);
169 }
170
171 update(ref, method, values) {
172 values = _.mapValues(values, value => escapeKeys(value));
173 const numValues = _.size(values);
174 if (!numValues) return Promise.resolve();
175 if (method === 'update' || method === 'override') {
176 checkUpdateHasOnlyDescendantsWithNoOverlap(ref.path, values);
177 }
178 if (this._applyLocalWrite(values, method === 'override')) return Promise.resolve();
179 const pathPrefix = extractCommonPathPrefix(values);
180 relativizePaths(pathPrefix, values);
181 if (pathPrefix !== ref.path) ref = new Reference(ref._tree, pathPrefix, ref._annotations);
182 const url = this._rootUrl + pathPrefix;
183 const writeSerial = this._writeSerial;
184 const set = numValues === 1;
185 const operand = set ? values[''] : values;
186 return this._dispatcher.execute('write', set ? 'set' : 'update', ref, operand, () => {
187 const promise = this._bridge[set ? 'set' : 'update'](url, operand, writeSerial);
188 return promise.catch(e => {
189 if (!e.immediateFailure) return Promise.reject(e);
190 return promiseFinally(this._repair(ref, values), () => Promise.reject(e));
191 });
192 });
193 }
194
195 commit(ref, updateFunction) {
196 let tries = 0;
197 updateFunction = wrapPromiseCallback(updateFunction);
198
199 const attemptTransaction = () => {
200 if (tries++ >= 25) {
201 return Promise.reject(new Error('Transaction needed too many retries, giving up'));
202 }
203 const txn = new Transaction(ref);
204 let oldValue;
205 // Ensure that Vue's watcher queue gets emptied and computed properties are up to date before
206 // running the updateFunction.
207 return Vue.nextTick().then(() => {
208 oldValue = toFirebaseJson(txn.currentValue);
209 return updateFunction(txn);
210 }).then(() => {
211 if (!_.isEqual(oldValue, toFirebaseJson(txn.currentValue))) return attemptTransaction();
212 if (txn.outcome === 'abort') return txn; // early return to save time
213 const values = _.mapValues(txn.values, value => escapeKeys(value));
214 switch (txn.outcome) {
215 case 'cancel':
216 break;
217 case 'set':
218 if (this._applyLocalWrite({[ref.path]: values['']})) return Promise.resolve();
219 break;
220 case 'update':
221 checkUpdateHasOnlyDescendantsWithNoOverlap(ref.path, values);
222 if (this._applyLocalWrite(values)) return Promise.resolve();
223 relativizePaths(ref.path, values);
224 break;
225 default:
226 throw new Error('Invalid transaction outcome: ' + (txn.outcome || 'none'));
227 }
228 return this._bridge.transaction(
229 this._rootUrl + ref.path, oldValue, values, this._writeSerial
230 ).then(result => {
231 _.forEach(result.snapshots, snapshot => this._integrateSnapshot(snapshot));
232 return result.committed ? txn : attemptTransaction();
233 }, e => {
234 if (e.immediateFailure && (txn.outcome === 'set' || txn.outcome === 'update')) {
235 return promiseFinally(this._repair(ref, values), () => Promise.reject(e));
236 }
237 return Promise.reject(e);
238 });
239 });
240 };
241
242 return this._truss.peek(ref, () => {
243 return this._dispatcher.execute('write', 'commit', ref, undefined, attemptTransaction);
244 });
245 }
246
247 _repair(ref, values) {
248 // If a write fails early -- that is, before it gets applied to the Firebase client's local
249 // tree -- then we need to repair our own local tree manually since Firebase won't send events
250 // to unwind the change. This should be very rare since it's always due to a developer mistake
251 // so we don't need to be particularly efficient.
252 const basePath = ref.path;
253 const paths = _(values).keys().flatMap(key => {
254 let path = basePath;
255 if (key) path = joinPath(path, key);
256 return _.keys(this._coupler.findCoupledDescendantPaths(path));
257 }).value();
258 return Promise.all(_.map(paths, path => {
259 return this._bridge.once(this._rootUrl + path).then(snap => {
260 this._integrateSnapshot(snap);
261 });
262 }));
263 }
264
265 _applyLocalWrite(values, override) {
266 // TODO: correctly apply local writes that impact queries. Currently, a local write will update
267 // any objects currently selected by a query, but won't add or remove results.
268 this._writeSerial++;
269 this._localWriteTimestamp = this._truss.now;
270 const createdObjects = [];
271 let numLocal = 0;
272 _.forEach(values, (value, path) => {
273 const local = this._modeler.isLocal(path, value);
274 if (local) numLocal++;
275 const coupledDescendantPaths =
276 local ? {[path]: true} : this._coupler.findCoupledDescendantPaths(path);
277 if (_.isEmpty(coupledDescendantPaths)) return;
278 const offset = (path === '/' ? 0 : path.length) + 1;
279 for (const descendantPath in coupledDescendantPaths) {
280 const subPath = descendantPath.slice(offset);
281 let subValue = value;
282 if (subPath && value !== null && value !== undefined) {
283 for (const segment of splitPath(subPath)) {
284 subValue = subValue.$data[segment];
285 if (subValue === undefined) break;
286 }
287 }
288 if (_.isNil(subValue)) {
289 this._prune(descendantPath);
290 } else {
291 const key = _.last(splitPath(descendantPath));
292 this._plantValue(
293 descendantPath, key, subValue,
294 this._scaffoldAncestors(descendantPath, false, createdObjects), false, override, local,
295 createdObjects
296 );
297 }
298 if (!override && !local) this._localWrites[descendantPath] = this._writeSerial;
299 }
300 });
301 for (const object of createdObjects) this._completeCreateObject(object);
302 if (numLocal && numLocal < _.size(values)) {
303 throw new Error('Write on a mix of local and remote tree paths.');
304 }
305 return override || !!numLocal;
306 }
307
308 /**
309 * Creates a Truss object and sets all its basic properties: path segment variables, user-defined
310 * properties, and computed properties. The latter two will be enumerable so that Vue will pick
311 * them up and make the reactive, so you should call _completeCreateObject once it's done so and
312 * before any Firebase properties are added.
313 */
314 _createObject(path, parent) {
315 if (!this._initialized && path !== '/') this.init();
316 const properties = {
317 // We want Vue to wrap this; we'll make it non-enumerable in _fixObject.
318 $parent: {value: parent, configurable: true, enumerable: true},
319 $path: {value: path}
320 };
321 if (path === '/') properties.$truss = {value: this._truss};
322
323 const object = this._modeler.createObject(path, properties);
324 Object.defineProperties(object, properties);
325 return object;
326 }
327
328 // To be called on the result of _createObject after it's been inserted into the _vue hierarchy
329 // and Vue has had a chance to initialize it.
330 _fixObject(object) {
331 for (const name of Object.getOwnPropertyNames(object)) {
332 const descriptor = Object.getOwnPropertyDescriptor(object, name);
333 if (descriptor.configurable && descriptor.enumerable) {
334 descriptor.enumerable = false;
335 if (_.startsWith(name, '$')) descriptor.configurable = false;
336 Object.defineProperty(object, name, descriptor);
337 }
338 }
339 }
340
341 // To be called on the result of _createObject after _fixObject, and after any additional Firebase
342 // properties have been set, to run initialiers.
343 _completeCreateObject(object) {
344 if (object.hasOwnProperty('$$initializers')) {
345 for (const fn of object.$$initializers) fn(this._vue);
346 delete object.$$initializers;
347 }
348 }
349
350 _destroyObject(object) {
351 if (!(object && object.$truss) || object.$destroyed) return;
352 this._modeler.destroyObject(object);
353 // Normally we'd only destroy enumerable children, which are the Firebase properties. However,
354 // clients have the option of creating hidden placeholders, so we need to scan non-enumerable
355 // properties as well. To distinguish such placeholders from the myriad other non-enumerable
356 // properties (that lead all over tree, e.g. $parent), we check that the property's parent is
357 // ourselves before destroying.
358 for (const key of Object.getOwnPropertyNames(object.$data)) {
359 const child = object.$data[key];
360 if (child && child.$parent === object) this._destroyObject(child);
361 }
362 }
363
364 _integrateSnapshot(snap) {
365 _.forEach(this._localWrites, (writeSerial, path) => {
366 if (snap.writeSerial >= writeSerial) delete this._localWrites[path];
367 });
368 if (snap.exists) {
369 const createdObjects = [];
370 const parent = this._scaffoldAncestors(snap.path, true, createdObjects);
371 if (parent) {
372 this._plantValue(
373 snap.path, snap.key, snap.value, parent, true, false, false, createdObjects);
374 }
375 for (const object of createdObjects) this._completeCreateObject(object);
376 } else {
377 this._prune(snap.path, null, true);
378 }
379 }
380
381 _scaffoldAncestors(path, remoteWrite, createdObjects) {
382 let object;
383 const segments = _.dropRight(splitPath(path, true));
384 let ancestorPath = '/';
385 for (let i = 0; i < segments.length; i++) {
386 const segment = segments[i];
387 const key = unescapeKey(segment);
388 let child = segment ? object.$data[key] : this.root;
389 if (segment) ancestorPath += (ancestorPath === '/' ? '' : '/') + segment;
390 if (child) {
391 if (remoteWrite && this._localWrites[ancestorPath]) return;
392 } else {
393 child = this._plantValue(
394 ancestorPath, key, {}, object, remoteWrite, false, false, createdObjects);
395 if (!child) return;
396 }
397 object = child;
398 }
399 return object;
400 }
401
402 _plantValue(path, key, value, parent, remoteWrite, override, local, createdObjects) {
403 if (remoteWrite && _.isNil(value)) {
404 throw new Error(`Snapshot includes invalid value at ${path}: ${value}`);
405 }
406 if (remoteWrite && this._localWrites[path || '/']) return;
407 if (_.isEqual(value, SERVER_TIMESTAMP)) value = this._localWriteTimestamp;
408 let object = parent.$data[key];
409 if (!_.isArray(value) && !(local ? _.isPlainObject(value) : _.isObject(value))) {
410 this._destroyObject(object);
411 if (!local && _.isNil(value)) {
412 this._deleteFirebaseProperty(parent, key);
413 } else {
414 this._setFirebaseProperty(parent, key, value);
415 }
416 return;
417 }
418 let objectCreated = false;
419 if (!_.isObject(object)) {
420 // Need to pre-set the property, so that if the child object attempts to watch any of its own
421 // properties while being created the $$touchThis method has something to add a dependency on
422 // as the object's own properties won't be made reactive until *after* it's been created.
423 this._setFirebaseProperty(parent, key, null);
424 object = this._createObject(path, parent);
425 this._setFirebaseProperty(parent, key, object, object.$hidden);
426 this._fixObject(object);
427 createdObjects.push(object);
428 objectCreated = true;
429 }
430 if (override) {
431 Object.defineProperty(object, '$overridden', {get: _.constant(true), configurable: true});
432 } else if (object.$overridden) {
433 delete object.$overridden;
434 }
435 // Plant hidden placeholders first, so their computed watchers will have a similar precedence to
436 // the parent object, and the parent object's other children will get computed first. This can
437 // optimize updates when parts of a complex model are broken out into hidden sub-models, and
438 // shouldn't risk being overwritten by actual Firebase data since that will rarely (never?) be
439 // hidden.
440 if (objectCreated) this._plantPlaceholders(object, path, true, createdObjects);
441 _.forEach(value, (item, escapedChildKey) => {
442 this._plantValue(
443 joinPath(path, escapedChildKey), unescapeKey(escapedChildKey), item, object, remoteWrite,
444 override, local, createdObjects
445 );
446 });
447 if (objectCreated) {
448 this._plantPlaceholders(object, path, false, createdObjects);
449 } else {
450 _.forEach(object.$data, (item, childKey) => {
451 const escapedChildKey = escapeKey(childKey);
452 if (!value.hasOwnProperty(escapedChildKey)) {
453 this._prune(joinPath(path, escapedChildKey), null, remoteWrite);
454 }
455 });
456 }
457 return object;
458 }
459
460 _plantPlaceholders(object, path, hidden, createdObjects) {
461 this._modeler.forEachPlaceholderChild(path, mount => {
462 if (hidden !== undefined && hidden !== !!mount.hidden) return;
463 const key = unescapeKey(mount.escapedKey);
464 if (!object.$data.hasOwnProperty(key)) {
465 this._plantValue(
466 joinPath(path, mount.escapedKey), key, mount.placeholder, object, false, false, false,
467 createdObjects);
468 }
469 });
470 }
471
472 _prune(path, lockedDescendantPaths, remoteWrite) {
473 lockedDescendantPaths = lockedDescendantPaths || {};
474 const object = this.getObject(path);
475 if (object === undefined) return;
476 if (remoteWrite && this._avoidLocalWritePaths(path, lockedDescendantPaths)) return;
477 if (!(_.isEmpty(lockedDescendantPaths) && this._pruneAncestors(path, object)) &&
478 _.isObject(object)) {
479 // The target object is a placeholder, and all ancestors are placeholders or otherwise needed
480 // as well, so we can't delete it. Instead, dive into its descendants to delete what we can.
481 this._pruneDescendants(object, lockedDescendantPaths);
482 }
483 }
484
485 _avoidLocalWritePaths(path, lockedDescendantPaths) {
486 for (const localWritePath in this._localWrites) {
487 if (!this._localWrites.hasOwnProperty(localWritePath)) continue;
488 if (path === localWritePath || localWritePath === '/' ||
489 _.startsWith(path, localWritePath + '/')) return true;
490 if (path === '/' || _.startsWith(localWritePath, path + '/')) {
491 const segments = splitPath(localWritePath, true);
492 for (let i = segments.length; i > 0; i--) {
493 const subPath = segments.slice(0, i).join('/');
494 const active = i === segments.length;
495 if (lockedDescendantPaths[subPath] || lockedDescendantPaths[subPath] === active) break;
496 lockedDescendantPaths[subPath] = active;
497 if (subPath === path) break;
498 }
499 }
500 }
501 }
502
503 _pruneAncestors(targetPath, targetObject) {
504 // Destroy the child (unless it's a placeholder that's still needed) and any ancestors that
505 // are no longer needed to keep this child rooted, and have no other reason to exist.
506 let deleted = false;
507 let object = targetObject;
508 // The target object may be a primitive, in which case it won't have $path, $parent and $key
509 // properties. In that case, use the target path to figure those out instead. Note that all
510 // ancestors of the target object will necessarily not be primitives and will have those
511 // properties.
512 let targetKey;
513 const targetParentPath = targetPath.replace(/\/[^/]+$/, match => {
514 targetKey = unescapeKey(match.slice(1));
515 return '';
516 });
517 while (object !== undefined && object !== this.root) {
518 const parent =
519 object && object.$parent || object === targetObject && this.getObject(targetParentPath);
520 if (!this._modeler.isPlaceholder(object && object.$path || targetPath)) {
521 const ghostObjects = deleted ? null : [targetObject];
522 if (!this._holdsConcreteData(object, ghostObjects)) {
523 deleted = true;
524 this._deleteFirebaseProperty(
525 parent, object && object.$key || object === targetObject && targetKey);
526 }
527 }
528 object = parent;
529 }
530 return deleted;
531 }
532
533 _holdsConcreteData(object, ghostObjects) {
534 if (_.isNil(object)) return false;
535 if (ghostObjects && _.includes(ghostObjects, object)) return false;
536 if (!_.isObject(object) || !object.$truss) return true;
537 return _.some(object.$data, value => this._holdsConcreteData(value, ghostObjects));
538 }
539
540 _pruneDescendants(object, lockedDescendantPaths) {
541 if (lockedDescendantPaths[object.$path]) return true;
542 if (object.$overridden) delete object.$overridden;
543 let coupledDescendantFound = false;
544 _.forEach(object.$data, (value, key) => {
545 let shouldDelete = true;
546 let valueLocked;
547 if (lockedDescendantPaths[joinPath(object.$path, escapeKey(key))]) {
548 shouldDelete = false;
549 valueLocked = true;
550 } else if (!_.isNil(value) && value.$truss) {
551 const placeholder = this._modeler.isPlaceholder(value.$path);
552 if (placeholder || _.has(lockedDescendantPaths, value.$path)) {
553 valueLocked = this._pruneDescendants(value, lockedDescendantPaths);
554 shouldDelete = !placeholder && !valueLocked;
555 }
556 }
557 if (shouldDelete) this._deleteFirebaseProperty(object, key);
558 coupledDescendantFound = coupledDescendantFound || valueLocked;
559 });
560 return coupledDescendantFound;
561 }
562
563 getObject(path) {
564 const segments = splitPath(path);
565 let object;
566 for (const segment of segments) {
567 object = segment ? object.$data[segment] : this.root;
568 if (object === undefined) return;
569 }
570 return object;
571 }
572
573 _getFirebasePropertyDescriptor(object, data, key) {
574 const descriptor = Object.getOwnPropertyDescriptor(data, key);
575 if (descriptor) {
576 if (!descriptor.enumerable) {
577 const child = data[key];
578 if (!child || child.$parent !== object) {
579 throw new Error(
580 `Key conflict between Firebase and instance or computed properties at ` +
581 `${object.$path}: ${key}`);
582 }
583 }
584 if (!descriptor.get || !descriptor.set) {
585 throw new Error(`Unbound property at ${object.$path}: ${key}`);
586 }
587 } else if (key in data) {
588 throw new Error(
589 `Key conflict between Firebase and inherited property at ${object.$path}: ${key}`);
590 }
591 return descriptor;
592 }
593
594 _setFirebaseProperty(object, key, value, hidden) {
595 const data = object.hasOwnProperty('$data') ? object.$data : object;
596 let descriptor = this._getFirebasePropertyDescriptor(object, data, key);
597 if (descriptor) {
598 if (hidden) {
599 // Redefine property as hidden after it's been created, since we usually don't know whether
600 // it should be hidden until too late. This is a one-way deal -- you can't unhide a
601 // property later, but that's fine for our purposes.
602 Object.defineProperty(data, key, {
603 get: descriptor.get, set: descriptor.set, configurable: true, enumerable: false
604 });
605 }
606 if (data[key] === value) return;
607 this._firebasePropertyEditAllowed = true;
608 data[key] = value;
609 this._firebasePropertyEditAllowed = false;
610 } else {
611 Vue.set(data, key, value);
612 descriptor = Object.getOwnPropertyDescriptor(data, key);
613 Object.defineProperty(data, key, {
614 get: descriptor.get, set: this._overwriteFirebaseProperty.bind(this, descriptor, key),
615 configurable: true, enumerable: !hidden
616 });
617 }
618 angular.digest();
619 }
620
621 _overwriteFirebaseProperty(descriptor, key, newValue) {
622 if (!this._firebasePropertyEditAllowed) {
623 const e = new Error(`Firebase data cannot be mutated directly: ${key}`);
624 e.trussCode = 'firebase_overwrite';
625 throw e;
626 }
627 descriptor.set.call(this, newValue);
628 }
629
630 _deleteFirebaseProperty(object, key) {
631 const data = object.hasOwnProperty('$data') ? object.$data : object;
632 // Make sure it's actually a Firebase property.
633 this._getFirebasePropertyDescriptor(object, data, key);
634 this._destroyObject(data[key]);
635 Vue.delete(data, key);
636 angular.digest();
637 }
638
639 checkVueObject(object, path) {
640 this._modeler.checkVueObject(object, path);
641 }
642}
643
644
645export function checkUpdateHasOnlyDescendantsWithNoOverlap(rootPath, values) {
646 // First, check all paths for correctness and absolutize them, since there could be a mix of
647 // absolute paths and relative keys.
648 _.forEach(_.keys(values), path => {
649 if (path.charAt(0) === '/') {
650 if (!(path === rootPath || rootPath === '/' ||
651 _.startsWith(path, rootPath + '/') && path.length > rootPath.length + 1)) {
652 throw new Error(`Update item is not a descendant of target ref: ${path}`);
653 }
654 } else {
655 if (_.includes(path, '/')) {
656 throw new Error(`Update item deep path must be absolute, taken from a reference: ${path}`);
657 }
658 const absolutePath = joinPath(rootPath, escapeKey(path));
659 if (values.hasOwnProperty(absolutePath)) {
660 throw new Error(`Update items overlap: ${path} and ${absolutePath}`);
661 }
662 values[absolutePath] = values[path];
663 delete values[path];
664 }
665 });
666 // Then check for overlaps;
667 const allPaths = _(values).keys().map(path => joinPath(path, '')).sortBy('length').value();
668 _.forEach(values, (value, path) => {
669 for (const otherPath of allPaths) {
670 if (otherPath.length > path.length) break;
671 if (path !== otherPath && _.startsWith(path, otherPath)) {
672 throw new Error(`Update items overlap: ${otherPath} and ${path}`);
673 }
674 }
675 });
676}
677
678export function extractCommonPathPrefix(values) {
679 let prefixSegments;
680 _.forEach(values, (value, path) => {
681 const segments = path === '/' ? [''] : splitPath(path, true);
682 if (prefixSegments) {
683 let firstMismatchIndex = 0;
684 const maxIndex = Math.min(prefixSegments.length, segments.length);
685 while (firstMismatchIndex < maxIndex &&
686 prefixSegments[firstMismatchIndex] === segments[firstMismatchIndex]) {
687 firstMismatchIndex++;
688 }
689 prefixSegments = prefixSegments.slice(0, firstMismatchIndex);
690 if (!prefixSegments.length) return false;
691 } else {
692 prefixSegments = segments;
693 }
694 });
695 return prefixSegments.length === 1 ? '/' : prefixSegments.join('/');
696}
697
698export function relativizePaths(rootPath, values) {
699 const offset = rootPath === '/' ? 1 : rootPath.length + 1;
700 _.forEach(_.keys(values), path => {
701 values[path.slice(offset)] = values[path];
702 delete values[path];
703 });
704}
705
706export function toFirebaseJson(object) {
707 if (!_.isObject(object)) return object;
708 const result = {};
709 const data = object.$data;
710 for (const key in data) {
711 if (data.hasOwnProperty(key)) result[escapeKey(key)] = toFirebaseJson(data[key]);
712 }
713 return result;
714}
715