UNPKG

22.1 kBJavaScriptView Raw
1import {Reference, Handle} from './Reference.js';
2import angular from './angularCompatibility.js';
3import stats from './utils/stats.js';
4import {makePathMatcher, joinPath, splitPath, escapeKey, unescapeKey} from './utils/paths.js';
5import {isTrussEqual, copyPrototype} from './utils/utils.js';
6import {promiseFinally} from './utils/promises.js';
7
8import _ from 'lodash';
9import performanceNow from 'performance-now';
10
11// These are defined separately for each object so they're not included in Value below.
12const RESERVED_VALUE_PROPERTY_NAMES = {$$$trussCheck: true, __ob__: true};
13
14// Holds properties that we're going to set on a model object that's being created right now as soon
15// as it's been created, but that we'd like to be accessible in the constructor. The object
16// prototype's getters will pick those up until they get overridden in the instance.
17let creatingObjectProperties;
18
19let currentPropertyFrozen;
20
21
22export class BaseValue {
23 get $meta() {return this.$truss.meta;}
24 get $store() {return this.$truss.store;} // access indirectly to leave dependency trace
25 get $now() {return this.$truss.now;}
26
27 $newKey() {return this.$truss.newKey();}
28
29 $intercept(actionType, callbacks) {
30 if (this.$destroyed) throw new Error('Object already destroyed');
31 const unintercept = this.$truss.intercept(actionType, callbacks);
32 const uninterceptAndRemoveFinalizer = () => {
33 unintercept();
34 _.pull(this.$$finalizers, uninterceptAndRemoveFinalizer);
35 };
36 this.$$finalizers.push(uninterceptAndRemoveFinalizer);
37 return uninterceptAndRemoveFinalizer;
38 }
39
40 $connect(scope, connections) {
41 if (this.$destroyed) throw new Error('Object already destroyed');
42 if (!connections) {
43 connections = scope;
44 scope = undefined;
45 }
46 const connector = this.$truss.connect(scope, wrapConnections(this, connections));
47 const originalDestroy = connector.destroy;
48 const destroy = () => {
49 _.pull(this.$$finalizers, destroy);
50 return originalDestroy.call(connector);
51 };
52 this.$$finalizers.push(destroy);
53 connector.destroy = destroy;
54 return connector;
55 }
56
57 $peek(target, callback) {
58 if (this.$destroyed) throw new Error('Object already destroyed');
59 const promise = promiseFinally(
60 this.$truss.peek(target, callback), () => {_.pull(this.$$finalizers, promise.cancel);}
61 );
62 this.$$finalizers.push(promise.cancel);
63 return promise;
64 }
65
66 $observe(subjectFn, callbackFn, options) {
67 if (this.$destroyed) throw new Error('Object already destroyed');
68 let unobserveAndRemoveFinalizer;
69
70 const unobserve = this.$truss.observe(() => {
71 this.$$touchThis();
72 return subjectFn.call(this);
73 }, callbackFn.bind(this), options);
74
75 unobserveAndRemoveFinalizer = () => { // eslint-disable-line prefer-const
76 unobserve();
77 _.pull(this.$$finalizers, unobserveAndRemoveFinalizer);
78 };
79 this.$$finalizers.push(unobserveAndRemoveFinalizer);
80 return unobserveAndRemoveFinalizer;
81 }
82
83 $when(expression, options) {
84 if (this.$destroyed) throw new Error('Object already destroyed');
85 const promise = this.$truss.when(() => {
86 this.$$touchThis();
87 return expression.call(this);
88 }, options);
89 promiseFinally(promise, () => {_.pull(this.$$finalizers, promise.cancel);});
90 this.$$finalizers.push(promise.cancel);
91 return promise;
92 }
93
94 get $$finalizers() {
95 Object.defineProperty(this, '$$finalizers', {
96 value: [], writable: false, enumerable: false, configurable: false});
97 return this.$$finalizers;
98 }
99}
100
101
102class Value {
103 get $parent() {return creatingObjectProperties.$parent.value;}
104 get $path() {return creatingObjectProperties.$path.value;}
105 get $truss() {
106 Object.defineProperty(this, '$truss', {value: this.$parent.$truss});
107 return this.$truss;
108 }
109 get $ref() {
110 Object.defineProperty(this, '$ref', {value: new Reference(this.$truss._tree, this.$path)});
111 return this.$ref;
112 }
113 get $refs() {return this.$ref;}
114 get $key() {
115 Object.defineProperty(
116 this, '$key', {value: unescapeKey(this.$path.slice(this.$path.lastIndexOf('/') + 1))});
117 return this.$key;
118 }
119 get $data() {return this;}
120 get $hidden() {return false;} // eslint-disable-line lodash/prefer-constant
121 get $empty() {return _.isEmpty(this.$data);}
122 get $keys() {return _.keys(this.$data);}
123 get $values() {return _.values(this.$data);}
124 get $ready() {return this.$ref.ready;}
125 get $overridden() {return false;} // eslint-disable-line lodash/prefer-constant
126
127 $nextTick() {
128 if (this.$destroyed) throw new Error('Object already destroyed');
129 const promise = this.$truss.nextTick();
130 promiseFinally(promise, () => {_.pull(this.$$finalizers, promise.cancel);});
131 this.$$finalizers.push(promise.cancel);
132 return promise;
133 }
134
135 $freezeComputedProperty() {
136 if (!_.isBoolean(currentPropertyFrozen)) {
137 throw new Error('Cannot freeze a computed property outside of its getter function');
138 }
139 currentPropertyFrozen = true;
140 }
141
142 $set(value) {return this.$ref.set(value);}
143 $update(values) {return this.$ref.update(values);}
144 $override(values) {return this.$ref.override(values);}
145 $commit(options, updateFn) {return this.$ref.commit(options, updateFn);}
146
147 $$touchThis() {
148 /* eslint-disable no-unused-expressions */
149 if (this.__ob__) {
150 this.__ob__.dep.depend();
151 } else if (this.$parent) {
152 (this.$parent.hasOwnProperty('$data') ? this.$parent.$data : this.$parent)[this.$key];
153 } else {
154 this.$store;
155 }
156 /* eslint-enable no-unused-expressions */
157 }
158
159 get $$initializers() {
160 Object.defineProperty(this, '$$initializers', {
161 value: [], writable: false, enumerable: false, configurable: true});
162 return this.$$initializers;
163 }
164
165 get $destroyed() { // eslint-disable-line lodash/prefer-constant
166 return false;
167 }
168}
169
170copyPrototype(BaseValue, Value);
171
172_.forEach(Value.prototype, (prop, name) => {
173 Object.defineProperty(
174 Value.prototype, name, {value: prop, enumerable: false, configurable: false, writable: false});
175});
176
177
178class ErrorWrapper {
179 constructor(error) {
180 this.error = error;
181 }
182}
183
184
185class FrozenWrapper {
186 constructor(value) {
187 this.value = value;
188 }
189}
190
191
192export default class Modeler {
193 constructor(debug) {
194 this._trie = {Class: Value};
195 this._debug = debug;
196 Object.freeze(this);
197 }
198
199 init(classes, rootAcceptable) {
200 if (_.isPlainObject(classes)) {
201 _.forEach(classes, (Class, path) => {
202 if (Class.$trussMount) return;
203 Class.$$trussMount = Class.$$trussMount || [];
204 Class.$$trussMount.push(path);
205 });
206 classes = _.values(classes);
207 _.forEach(classes, Class => {
208 if (!Class.$trussMount && Class.$$trussMount) {
209 Class.$trussMount = Class.$$trussMount;
210 delete Class.$$trussMount;
211 }
212 });
213 }
214 classes = _.uniq(classes);
215 _.forEach(classes, Class => this._mountClass(Class, rootAcceptable));
216 this._decorateTrie(this._trie);
217 }
218
219 destroy() { // eslint-disable-line no-empty-function
220 }
221
222 _getMount(path, scaffold, predicate) {
223 const segments = splitPath(path, true);
224 let node;
225 for (const segment of segments) {
226 let child = segment ?
227 node.children && (node.children[segment] || !scaffold && node.children.$) : this._trie;
228 if (!child) {
229 if (!scaffold) return;
230 node.children = node.children || {};
231 child = node.children[segment] = {Class: Value};
232 }
233 node = child;
234 if (predicate && predicate(node)) break;
235 }
236 return node;
237 }
238
239 _findMount(predicate, node) {
240 if (!node) node = this._trie;
241 if (predicate(node)) return node;
242 for (const childKey of _.keys(node.children)) {
243 const result = this._findMount(predicate, node.children[childKey]);
244 if (result) return result;
245 }
246 }
247
248 _decorateTrie(node) {
249 _.forEach(node.children, child => {
250 this._decorateTrie(child);
251 if (child.local || child.localDescendants) node.localDescendants = true;
252 });
253 }
254
255 _augmentClass(Class) {
256 let computedProperties;
257 let proto = Class.prototype;
258 while (proto && proto.constructor !== Object) {
259 for (const name of Object.getOwnPropertyNames(proto)) {
260 const descriptor = Object.getOwnPropertyDescriptor(proto, name);
261 if (name.charAt(0) === '$') {
262 if (name === '$finalize') continue;
263 if (_.isEqual(descriptor, Object.getOwnPropertyDescriptor(Value.prototype, name))) {
264 continue;
265 }
266 throw new Error(`Property names starting with "$" are reserved: ${Class.name}.${name}`);
267 }
268 if (descriptor.get && !(computedProperties && computedProperties[name])) {
269 (computedProperties || (computedProperties = {}))[name] = {
270 name, fullName: `${proto.constructor.name}.${name}`, get: descriptor.get,
271 set: descriptor.set
272 };
273 }
274 }
275 proto = Object.getPrototypeOf(proto);
276 }
277 for (const name of Object.getOwnPropertyNames(Value.prototype)) {
278 if (name === 'constructor' || Class.prototype.hasOwnProperty(name)) continue;
279 Object.defineProperty(
280 Class.prototype, name, Object.getOwnPropertyDescriptor(Value.prototype, name));
281 }
282 return computedProperties;
283 }
284
285 _mountClass(Class, rootAcceptable) {
286 const computedProperties = this._augmentClass(Class);
287 const allVariables = [];
288 let mounts = Class.$trussMount;
289 if (!mounts) throw new Error(`Class ${Class.name} lacks a $trussMount static property`);
290 if (!_.isArray(mounts)) mounts = [mounts];
291 _.forEach(mounts, mount => {
292 if (_.isString(mount)) mount = {path: mount};
293 if (!rootAcceptable && mount.path === '/') {
294 throw new Error('Data root already accessed, too late to mount class');
295 }
296 const matcher = makePathMatcher(mount.path);
297 for (const variable of matcher.variables) {
298 if (variable === '$' || variable.charAt(1) === '$') {
299 throw new Error(`Invalid variable name: ${variable}`);
300 }
301 if (variable.charAt(0) === '$' && (
302 _.has(Value.prototype, variable) || RESERVED_VALUE_PROPERTY_NAMES[variable]
303 )) {
304 throw new Error(`Variable name conflicts with built-in property or method: ${variable}`);
305 }
306 allVariables.push(variable);
307 }
308 const escapedKey = mount.path.match(/\/([^/]*)$/)[1];
309 if (escapedKey.charAt(0) === '$') {
310 if (mount.placeholder) {
311 throw new Error(
312 `Class ${Class.name} mounted at wildcard ${escapedKey} cannot be a placeholder`);
313 }
314 } else if (!_.has(mount, 'placeholder')) {
315 mount.placeholder = {};
316 }
317 const targetMount = this._getMount(mount.path.replace(/\$[^/]*/g, '$'), true);
318 if (targetMount.matcher && (
319 targetMount.escapedKey === escapedKey ||
320 targetMount.escapedKey.charAt(0) === '$' && escapedKey.charAt(0) === '$'
321 )) {
322 throw new Error(
323 `Multiple classes mounted at ${mount.path}: ${targetMount.Class.name}, ${Class.name}`);
324 }
325 _.assign(
326 targetMount, {Class, matcher, computedProperties, escapedKey},
327 _.pick(mount, 'placeholder', 'local', 'keysUnsafe', 'hidden'));
328 });
329 _(allVariables).uniq().forEach(variable => {
330 Object.defineProperty(Class.prototype, variable, {get() {
331 return creatingObjectProperties ?
332 creatingObjectProperties[variable] && creatingObjectProperties[variable].value :
333 undefined;
334 }});
335 });
336 }
337
338 /**
339 * Creates a Truss object and sets all its basic properties: path segment variables, user-defined
340 * properties, and computed properties. The latter two will be enumerable so that Vue will pick
341 * them up and make the reactive.
342 */
343 createObject(path, properties) {
344 const mount = this._getMount(path) || {Class: Value};
345 try {
346 if (mount.matcher) {
347 const match = mount.matcher.match(path);
348 for (const variable in match) {
349 properties[variable] = {value: match[variable]};
350 }
351 }
352
353 creatingObjectProperties = properties;
354 const object = new mount.Class();
355 creatingObjectProperties = null;
356
357 if (angular.active) this._wrapProperties(object);
358
359 if (mount.keysUnsafe) {
360 properties.$data = {value: Object.create(null), configurable: true, enumerable: true};
361 }
362 if (mount.hidden) properties.$hidden = {value: true};
363 if (mount.computedProperties) {
364 _.forEach(mount.computedProperties, prop => {
365 properties[prop.name] = this._buildComputedPropertyDescriptor(object, prop);
366 });
367 }
368
369 return object;
370 } catch (e) {
371 e.extra = _.assign({mount, properties, className: mount.Class && mount.Class.name}, e.extra);
372 throw e;
373 }
374 }
375
376 _wrapProperties(object) {
377 _.forEach(object, (value, key) => {
378 const valueKey = '$_' + key;
379 Object.defineProperties(object, {
380 [valueKey]: {value, writable: true},
381 [key]: {
382 get: () => object[valueKey],
383 set: arg => {object[valueKey] = arg; angular.digest();},
384 enumerable: true, configurable: true
385 }
386 });
387 });
388 }
389
390 _buildComputedPropertyDescriptor(object, prop) {
391 const propertyStats = stats.for(prop.fullName);
392
393 let value, pendingPromise;
394 let writeAllowed = false;
395
396 object.$$initializers.push(vue => {
397 let unwatchNow = false;
398 const compute = computeValue.bind(object, prop, propertyStats);
399 if (this._debug) compute.toString = () => {return prop.fullName;};
400 let unwatch = () => {unwatchNow = true;};
401 unwatch = vue.$watch(compute, newValue => {
402 if (object.$destroyed) {
403 unwatch();
404 return;
405 }
406 if (pendingPromise) {
407 if (pendingPromise.cancel) pendingPromise.cancel();
408 pendingPromise = undefined;
409 }
410 if (_.isObject(newValue) && _.isFunction(newValue.then)) {
411 const promise = newValue.then(finalValue => {
412 if (promise === pendingPromise) update(finalValue);
413 // No need to angular.digest() here, since if we're running under Angular then we expect
414 // promises to be aliased to its $q service, which triggers digest itself.
415 }, error => {
416 if (promise === pendingPromise && update(new ErrorWrapper(error)) &&
417 !error.trussExpectedException) throw error;
418 });
419 pendingPromise = promise;
420 } else if (update(newValue)) {
421 angular.digest();
422 if (newValue instanceof ErrorWrapper && !newValue.error.trussExpectedException) {
423 throw newValue.error;
424 }
425 }
426 }, {immediate: true}); // use immediate:true since watcher will run computeValue anyway
427 // Hack to change order of computed property watchers. By flipping their ids to be negative,
428 // we ensure that they will settle before all other watchers, and also that children
429 // properties will settle before their parents since values are often aggregated upwards.
430 const watcher = _.last(vue._watchers || vue._scope.effects);
431 watcher.id = -watcher.id;
432
433 function update(newValue) {
434 if (newValue instanceof FrozenWrapper) {
435 newValue = newValue.value;
436 unwatch();
437 _.pull(object.$$finalizers, unwatch);
438 }
439 if (isTrussEqual(value, newValue)) return;
440 // console.log('updating', object.$key, prop.fullName, 'from', value, 'to', newValue);
441 propertyStats.numUpdates += 1;
442 writeAllowed = true;
443 object[prop.name] = newValue;
444 writeAllowed = false;
445 // Freeze the computed value so it can't be accidentally modified by a third party. Ideally
446 // we'd freeze it before setting it so that Vue wouldn't instrument the object recursively
447 // (since it can't change anyway), but we actually need the instrumentation in case a client
448 // tries to access an inexistent property off a computed pointer to an unfrozen value (e.g.,
449 // a $truss-ified object). When instrumented, Vue will add a dependency on the unfrozen
450 // value in case the property is later added. If uninstrumented, the dependency won't be
451 // added and we won't be notified. And Vue only instruments extensible objects...
452 freeze(newValue);
453 return true;
454 }
455
456 if (unwatchNow) {
457 unwatch();
458 } else {
459 object.$$finalizers.push(unwatch);
460 }
461 });
462 return {
463 enumerable: true, configurable: true,
464 get() {
465 if (!writeAllowed && value instanceof ErrorWrapper) throw value.error;
466 return value;
467 },
468 set(newValue) {
469 if (writeAllowed) {
470 value = newValue;
471 } else if (prop.set) {
472 prop.set.call(this, newValue);
473 } else {
474 throw new Error(`You cannot set a computed property: ${prop.name}`);
475 }
476 }
477 };
478 }
479
480 destroyObject(object) {
481 if (_.has(object, '$$finalizers')) {
482 // Some finalizers remove themselves from the array, so clone it before iterating.
483 for (const fn of _.clone(object.$$finalizers)) fn();
484 }
485 if (_.isFunction(object.$finalize)) object.$finalize();
486 Object.defineProperty(
487 object, '$destroyed', {value: true, enumerable: false, configurable: false});
488 }
489
490 isPlaceholder(path) {
491 const mount = this._getMount(path);
492 return mount && mount.placeholder;
493 }
494
495 isLocal(path, value) {
496 // eslint-disable-next-line no-shadow
497 const mount = this._getMount(path, false, mount => mount.local);
498 if (mount && mount.local) return true;
499 if (this._hasLocalProperties(mount, value)) {
500 throw new Error('Write on a mix of local and remote tree paths.');
501 }
502 return false;
503 }
504
505 _hasLocalProperties(mount, value) {
506 if (!mount) return false;
507 if (mount.local) return true;
508 if (!mount.localDescendants || !_.isObject(value)) return false;
509 for (const key in value) {
510 const local =
511 this._hasLocalProperties(mount.children[escapeKey(key)] || mount.children.$, value[key]);
512 if (local) return true;
513 }
514 return false;
515 }
516
517 forEachPlaceholderChild(path, iteratee) {
518 const mount = this._getMount(path);
519 _.forEach(mount && mount.children, child => {
520 if (child.placeholder) iteratee(child);
521 });
522 }
523
524 checkVueObject(object, path, checkedObjects) {
525 const top = !checkedObjects;
526 if (top) checkedObjects = [];
527 try {
528 for (const key of Object.getOwnPropertyNames(object)) {
529 if (RESERVED_VALUE_PROPERTY_NAMES[key] || Value.prototype.hasOwnProperty(key) ||
530 /^\$_/.test(key)) continue;
531 // eslint-disable-next-line no-shadow
532 const mount = this._findMount(mount => mount.Class === object.constructor);
533 if (mount && mount.matcher && _.includes(mount.matcher.variables, key)) continue;
534 let value;
535 try {
536 value = object[key];
537 } catch (e) {
538 // Ignore any values that hold exceptions, or otherwise throw on access -- we won't be
539 // able to check them anyway.
540 continue;
541 }
542 if (!(_.isArray(object) && (/\d+/.test(key) || key === 'length'))) {
543 const descriptor = Object.getOwnPropertyDescriptor(object, key);
544 if ('value' in descriptor || !descriptor.get) {
545 throw new Error(
546 `Value at ${path}, contained in a Firetruss object, has a rogue property: ${key}`);
547 }
548 if (object.$truss && descriptor.enumerable) {
549 try {
550 object[key] = value;
551 throw new Error(
552 `Firetruss object at ${path} has an enumerable non-Firebase property: ${key}`);
553 } catch (e) {
554 if (e.trussCode !== 'firebase_overwrite') throw e;
555 }
556 }
557 }
558 if (_.isObject(value) && !value.$$$trussCheck && Object.isExtensible(value) &&
559 !(_.isFunction(value) || value instanceof Promise)) {
560 value.$$$trussCheck = true;
561 checkedObjects.push(value);
562 this.checkVueObject(value, joinPath(path, escapeKey(key)), checkedObjects);
563 }
564 }
565 } finally {
566 if (top) {
567 for (const item of checkedObjects) delete item.$$$trussCheck;
568 }
569 }
570 }
571}
572
573
574function computeValue(prop, propertyStats) {
575 /* eslint-disable no-invalid-this */
576 if (this.$destroyed) return;
577 // Touch this object, since a failed access to a missing property doesn't get captured as a
578 // dependency.
579 this.$$touchThis();
580
581 const oldPropertyFrozen = currentPropertyFrozen;
582 currentPropertyFrozen = false;
583 const startTime = performanceNow();
584 let value;
585 try {
586 try {
587 value = prop.get.call(this);
588 } catch (e) {
589 value = new ErrorWrapper(e);
590 } finally {
591 propertyStats.runtime += performanceNow() - startTime;
592 propertyStats.numRecomputes += 1;
593 }
594 if (currentPropertyFrozen) value = new FrozenWrapper(value);
595 return value;
596 } finally {
597 currentPropertyFrozen = oldPropertyFrozen;
598 }
599 /* eslint-enable no-invalid-this */
600}
601
602function wrapConnections(object, connections) {
603 if (!connections || connections instanceof Handle) return connections;
604 return _.mapValues(connections, descriptor => {
605 if (descriptor instanceof Handle) return descriptor;
606 if (_.isFunction(descriptor)) {
607 const fn = function() {
608 /* eslint-disable no-invalid-this */
609 object.$$touchThis();
610 return wrapConnections(object, descriptor.call(this));
611 /* eslint-enable no-invalid-this */
612 };
613 fn.angularWatchSuppressed = true;
614 return fn;
615 }
616 return wrapConnections(object, descriptor);
617 });
618}
619
620function freeze(object) {
621 if (_.isNil(object) || !_.isObject(object) || Object.isFrozen(object) || object.$truss) {
622 return object;
623 }
624 object = Object.freeze(object);
625 if (_.isArray(object)) return _.map(object, value => freeze(value));
626 return _.mapValues(object, value => freeze(value));
627}