1 | import angular from './angularCompatibility.js';
|
2 | import Coupler from './Coupler.js';
|
3 | import Modeler from './Modeler.js';
|
4 | import Reference from './Reference.js';
|
5 | import {escapeKey, escapeKeys, unescapeKey, joinPath, splitPath} from './utils/paths.js';
|
6 | import {wrapPromiseCallback, promiseFinally} from './utils/promises.js';
|
7 | import {SERVER_TIMESTAMP} from './utils/utils.js';
|
8 |
|
9 | import _ from 'lodash';
|
10 | import Vue from 'vue';
|
11 |
|
12 |
|
13 | class 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 |
|
52 | export 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 |
|
69 |
|
70 |
|
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);
|
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);
|
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);
|
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);
|
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 |
|
206 |
|
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;
|
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 |
|
249 |
|
250 |
|
251 |
|
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 |
|
267 |
|
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 |
|
310 |
|
311 |
|
312 |
|
313 |
|
314 | _createObject(path, parent) {
|
315 | if (!this._initialized && path !== '/') this.init();
|
316 | const properties = {
|
317 |
|
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 |
|
329 |
|
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 |
|
342 |
|
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 |
|
354 |
|
355 |
|
356 |
|
357 |
|
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 |
|
421 |
|
422 |
|
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 |
|
436 |
|
437 |
|
438 |
|
439 |
|
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 |
|
480 |
|
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 |
|
505 |
|
506 | let deleted = false;
|
507 | let object = targetObject;
|
508 |
|
509 |
|
510 |
|
511 |
|
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 |
|
600 |
|
601 |
|
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 |
|
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 |
|
645 | export function checkUpdateHasOnlyDescendantsWithNoOverlap(rootPath, values) {
|
646 |
|
647 |
|
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 |
|
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 |
|
678 | export 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 |
|
698 | export 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 |
|
706 | export 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 |
|