firetruss
Version:
Advanced data sync layer for Firebase and Vue.js
630 lines (569 loc) • 22.1 kB
JavaScript
import {Reference, Handle} from './Reference.js';
import angular from './angularCompatibility.js';
import stats from './utils/stats.js';
import {makePathMatcher, joinPath, splitPath, escapeKey, unescapeKey} from './utils/paths.js';
import {isTrussEqual, copyPrototype} from './utils/utils.js';
import {promiseFinally} from './utils/promises.js';
import _ from 'lodash';
import performanceNow from 'performance-now';
// These are defined separately for each object so they're not included in Value below.
const RESERVED_VALUE_PROPERTY_NAMES = {$$$trussCheck: true, __ob__: true};
// Holds properties that we're going to set on a model object that's being created right now as soon
// as it's been created, but that we'd like to be accessible in the constructor. The object
// prototype's getters will pick those up until they get overridden in the instance.
let creatingObjectProperties;
let currentPropertyFrozen;
export class BaseValue {
get $meta() {return this.$truss.meta;}
get $store() {return this.$truss.store;} // access indirectly to leave dependency trace
get $now() {return this.$truss.now;}
$newKey() {return this.$truss.newKey();}
$intercept(actionType, callbacks) {
if (this.$destroyed) throw new Error('Object already destroyed');
const unintercept = this.$truss.intercept(actionType, callbacks);
const uninterceptAndRemoveFinalizer = () => {
unintercept();
_.pull(this.$$finalizers, uninterceptAndRemoveFinalizer);
};
this.$$finalizers.push(uninterceptAndRemoveFinalizer);
return uninterceptAndRemoveFinalizer;
}
$connect(scope, connections) {
if (this.$destroyed) throw new Error('Object already destroyed');
if (!connections) {
connections = scope;
scope = undefined;
}
const connector = this.$truss.connect(scope, wrapConnections(this, connections));
const originalDestroy = connector.destroy;
const destroy = () => {
_.pull(this.$$finalizers, destroy);
return originalDestroy.call(connector);
};
this.$$finalizers.push(destroy);
connector.destroy = destroy;
return connector;
}
$peek(target, callback) {
if (this.$destroyed) throw new Error('Object already destroyed');
const promise = promiseFinally(
this.$truss.peek(target, callback), () => {_.pull(this.$$finalizers, promise.cancel);}
);
this.$$finalizers.push(promise.cancel);
return promise;
}
$observe(subjectFn, callbackFn, options) {
if (this.$destroyed) throw new Error('Object already destroyed');
let unobserveAndRemoveFinalizer;
const unobserve = this.$truss.observe(() => {
this.$$touchThis();
return subjectFn.call(this);
}, callbackFn.bind(this), options);
unobserveAndRemoveFinalizer = () => { // eslint-disable-line prefer-const
unobserve();
_.pull(this.$$finalizers, unobserveAndRemoveFinalizer);
};
this.$$finalizers.push(unobserveAndRemoveFinalizer);
return unobserveAndRemoveFinalizer;
}
$when(expression, options) {
if (this.$destroyed) throw new Error('Object already destroyed');
const promise = this.$truss.when(() => {
this.$$touchThis();
return expression.call(this);
}, options);
promiseFinally(promise, () => {_.pull(this.$$finalizers, promise.cancel);});
this.$$finalizers.push(promise.cancel);
return promise;
}
get $$finalizers() {
Object.defineProperty(this, '$$finalizers', {
value: [], writable: false, enumerable: false, configurable: false});
return this.$$finalizers;
}
}
class Value {
get $parent() {return creatingObjectProperties.$parent.value;}
get $path() {return creatingObjectProperties.$path.value;}
get $truss() {
Object.defineProperty(this, '$truss', {value: this.$parent.$truss});
return this.$truss;
}
get $ref() {
Object.defineProperty(this, '$ref', {value: new Reference(this.$truss._tree, this.$path)});
return this.$ref;
}
get $refs() {return this.$ref;}
get $key() {
Object.defineProperty(
this, '$key', {value: unescapeKey(this.$path.slice(this.$path.lastIndexOf('/') + 1))});
return this.$key;
}
get $data() {return this;}
get $hidden() {return false;} // eslint-disable-line lodash/prefer-constant
get $empty() {return _.isEmpty(this.$data);}
get $keys() {return _.keys(this.$data);}
get $values() {return _.values(this.$data);}
get $ready() {return this.$ref.ready;}
get $overridden() {return false;} // eslint-disable-line lodash/prefer-constant
$nextTick() {
if (this.$destroyed) throw new Error('Object already destroyed');
const promise = this.$truss.nextTick();
promiseFinally(promise, () => {_.pull(this.$$finalizers, promise.cancel);});
this.$$finalizers.push(promise.cancel);
return promise;
}
$freezeComputedProperty() {
if (!_.isBoolean(currentPropertyFrozen)) {
throw new Error('Cannot freeze a computed property outside of its getter function');
}
currentPropertyFrozen = true;
}
$set(value) {return this.$ref.set(value);}
$update(values) {return this.$ref.update(values);}
$override(values) {return this.$ref.override(values);}
$commit(options, updateFn) {return this.$ref.commit(options, updateFn);}
$$touchThis() {
/* eslint-disable no-unused-expressions */
if (this.__ob__) {
this.__ob__.dep.depend();
} else if (this.$parent) {
(this.$parent.hasOwnProperty('$data') ? this.$parent.$data : this.$parent)[this.$key];
} else {
this.$store;
}
/* eslint-enable no-unused-expressions */
}
get $$initializers() {
Object.defineProperty(this, '$$initializers', {
value: [], writable: false, enumerable: false, configurable: true});
return this.$$initializers;
}
get $destroyed() { // eslint-disable-line lodash/prefer-constant
return false;
}
}
copyPrototype(BaseValue, Value);
_.forEach(Value.prototype, (prop, name) => {
Object.defineProperty(
Value.prototype, name, {value: prop, enumerable: false, configurable: false, writable: false});
});
class ErrorWrapper {
constructor(error) {
this.error = error;
}
}
class FrozenWrapper {
constructor(value) {
this.value = value;
}
}
export default class Modeler {
constructor(debug) {
this._trie = {Class: Value};
this._debug = debug;
Object.freeze(this);
}
init(classes, rootAcceptable) {
if (_.isPlainObject(classes)) {
_.forEach(classes, (Class, path) => {
if (Class.$trussMount) return;
Class.$$trussMount = Class.$$trussMount || [];
Class.$$trussMount.push(path);
});
classes = _.values(classes);
_.forEach(classes, Class => {
if (!Class.$trussMount && Class.$$trussMount) {
Class.$trussMount = Class.$$trussMount;
delete Class.$$trussMount;
}
});
}
classes = _.uniq(classes);
_.forEach(classes, Class => this._mountClass(Class, rootAcceptable));
this._decorateTrie(this._trie);
}
destroy() { // eslint-disable-line no-empty-function
}
_getMount(path, scaffold, predicate) {
const segments = splitPath(path, true);
let node;
for (const segment of segments) {
let child = segment ?
node.children && (node.children[segment] || !scaffold && node.children.$) : this._trie;
if (!child) {
if (!scaffold) return;
node.children = node.children || {};
child = node.children[segment] = {Class: Value};
}
node = child;
if (predicate && predicate(node)) break;
}
return node;
}
_findMount(predicate, node) {
if (!node) node = this._trie;
if (predicate(node)) return node;
for (const childKey of _.keys(node.children)) {
const result = this._findMount(predicate, node.children[childKey]);
if (result) return result;
}
}
_decorateTrie(node) {
_.forEach(node.children, child => {
this._decorateTrie(child);
if (child.local || child.localDescendants) node.localDescendants = true;
});
}
_augmentClass(Class) {
let computedProperties;
let proto = Class.prototype;
while (proto && proto.constructor !== Object) {
for (const name of Object.getOwnPropertyNames(proto)) {
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
if (name.charAt(0) === '$') {
if (name === '$finalize') continue;
if (_.isEqual(descriptor, Object.getOwnPropertyDescriptor(Value.prototype, name))) {
continue;
}
throw new Error(`Property names starting with "$" are reserved: ${Class.name}.${name}`);
}
if (descriptor.get && !(computedProperties && computedProperties[name])) {
(computedProperties || (computedProperties = {}))[name] = {
name, fullName: `${proto.constructor.name}.${name}`, get: descriptor.get,
set: descriptor.set
};
}
}
proto = Object.getPrototypeOf(proto);
}
for (const name of Object.getOwnPropertyNames(Value.prototype)) {
if (name === 'constructor' || Class.prototype.hasOwnProperty(name)) continue;
Object.defineProperty(
Class.prototype, name, Object.getOwnPropertyDescriptor(Value.prototype, name));
}
return computedProperties;
}
_mountClass(Class, rootAcceptable) {
const computedProperties = this._augmentClass(Class);
const allVariables = [];
let mounts = Class.$trussMount;
if (!mounts) throw new Error(`Class ${Class.name} lacks a $trussMount static property`);
if (!_.isArray(mounts)) mounts = [mounts];
_.forEach(mounts, mount => {
if (_.isString(mount)) mount = {path: mount};
if (!rootAcceptable && mount.path === '/') {
throw new Error('Data root already accessed, too late to mount class');
}
const matcher = makePathMatcher(mount.path);
for (const variable of matcher.variables) {
if (variable === '$' || variable.charAt(1) === '$') {
throw new Error(`Invalid variable name: ${variable}`);
}
if (variable.charAt(0) === '$' && (
_.has(Value.prototype, variable) || RESERVED_VALUE_PROPERTY_NAMES[variable]
)) {
throw new Error(`Variable name conflicts with built-in property or method: ${variable}`);
}
allVariables.push(variable);
}
const escapedKey = mount.path.match(/\/([^/]*)$/)[1];
if (escapedKey.charAt(0) === '$') {
if (mount.placeholder) {
throw new Error(
`Class ${Class.name} mounted at wildcard ${escapedKey} cannot be a placeholder`);
}
} else if (!_.has(mount, 'placeholder')) {
mount.placeholder = {};
}
const targetMount = this._getMount(mount.path.replace(/\$[^/]*/g, '$'), true);
if (targetMount.matcher && (
targetMount.escapedKey === escapedKey ||
targetMount.escapedKey.charAt(0) === '$' && escapedKey.charAt(0) === '$'
)) {
throw new Error(
`Multiple classes mounted at ${mount.path}: ${targetMount.Class.name}, ${Class.name}`);
}
_.assign(
targetMount, {Class, matcher, computedProperties, escapedKey},
_.pick(mount, 'placeholder', 'local', 'keysUnsafe', 'hidden'));
});
_.forEach(allVariables, variable => {
if (!Class.prototype[variable]) {
Object.defineProperty(Class.prototype, variable, {get() {
return creatingObjectProperties ?
creatingObjectProperties[variable] && creatingObjectProperties[variable].value :
undefined;
}});
}
});
}
/**
* Creates a Truss object and sets all its basic properties: path segment variables, user-defined
* properties, and computed properties. The latter two will be enumerable so that Vue will pick
* them up and make the reactive.
*/
createObject(path, properties) {
const mount = this._getMount(path) || {Class: Value};
try {
if (mount.matcher) {
const match = mount.matcher.match(path);
for (const variable in match) {
properties[variable] = {value: match[variable]};
}
}
creatingObjectProperties = properties;
const object = new mount.Class();
creatingObjectProperties = null;
if (angular.active) this._wrapProperties(object);
if (mount.keysUnsafe) {
properties.$data = {value: Object.create(null), configurable: true, enumerable: true};
}
if (mount.hidden) properties.$hidden = {value: true};
if (mount.computedProperties) {
_.forEach(mount.computedProperties, prop => {
properties[prop.name] = this._buildComputedPropertyDescriptor(object, prop);
});
}
return object;
} catch (e) {
e.extra = _.assign({mount, properties, className: mount.Class && mount.Class.name}, e.extra);
throw e;
}
}
_wrapProperties(object) {
_.forEach(object, (value, key) => {
const valueKey = '$_' + key;
Object.defineProperties(object, {
[valueKey]: {value, writable: true},
[key]: {
get: () => object[valueKey],
set: arg => {object[valueKey] = arg; angular.digest();},
enumerable: true, configurable: true
}
});
});
}
_buildComputedPropertyDescriptor(object, prop) {
const propertyStats = stats.for(prop.fullName);
let value, pendingPromise;
let writeAllowed = false;
object.$$initializers.push(vue => {
let unwatchNow = false;
const compute = computeValue.bind(object, prop, propertyStats);
if (this._debug) compute.toString = () => {return prop.fullName;};
let unwatch = () => {unwatchNow = true;};
unwatch = vue.$watch(compute, newValue => {
if (object.$destroyed) {
unwatch();
return;
}
if (pendingPromise) {
if (pendingPromise.cancel) pendingPromise.cancel();
pendingPromise = undefined;
}
if (_.isObject(newValue) && _.isFunction(newValue.then)) {
const promise = newValue.then(finalValue => {
if (promise === pendingPromise) update(finalValue);
// No need to angular.digest() here, since if we're running under Angular then we expect
// promises to be aliased to its $q service, which triggers digest itself.
}, error => {
if (promise === pendingPromise && update(new ErrorWrapper(error)) &&
!error.trussExpectedException) throw error;
});
pendingPromise = promise;
} else if (update(newValue)) {
angular.digest();
if (newValue instanceof ErrorWrapper && !newValue.error.trussExpectedException) {
throw newValue.error;
}
}
}, {immediate: true}); // use immediate:true since watcher will run computeValue anyway
// Hack to change order of computed property watchers. By flipping their ids to be negative,
// we ensure that they will settle before all other watchers, and also that children
// properties will settle before their parents since values are often aggregated upwards.
const watcher = _.last(vue._watchers);
watcher.id = -watcher.id;
function update(newValue) {
if (newValue instanceof FrozenWrapper) {
newValue = newValue.value;
unwatch();
_.pull(object.$$finalizers, unwatch);
}
if (isTrussEqual(value, newValue)) return;
// console.log('updating', object.$key, prop.fullName, 'from', value, 'to', newValue);
propertyStats.numUpdates += 1;
writeAllowed = true;
object[prop.name] = newValue;
writeAllowed = false;
// Freeze the computed value so it can't be accidentally modified by a third party. Ideally
// we'd freeze it before setting it so that Vue wouldn't instrument the object recursively
// (since it can't change anyway), but we actually need the instrumentation in case a client
// tries to access an inexistent property off a computed pointer to an unfrozen value (e.g.,
// a $truss-ified object). When instrumented, Vue will add a dependency on the unfrozen
// value in case the property is later added. If uninstrumented, the dependency won't be
// added and we won't be notified. And Vue only instruments extensible objects...
freeze(newValue);
return true;
}
if (unwatchNow) {
unwatch();
} else {
object.$$finalizers.push(unwatch);
}
});
return {
enumerable: true, configurable: true,
get() {
if (!writeAllowed && value instanceof ErrorWrapper) throw value.error;
return value;
},
set(newValue) {
if (writeAllowed) {
value = newValue;
} else if (prop.set) {
prop.set.call(this, newValue);
} else {
throw new Error(`You cannot set a computed property: ${prop.name}`);
}
}
};
}
destroyObject(object) {
if (_.has(object, '$$finalizers')) {
// Some finalizers remove themselves from the array, so clone it before iterating.
for (const fn of _.clone(object.$$finalizers)) fn();
}
if (_.isFunction(object.$finalize)) object.$finalize();
Object.defineProperty(
object, '$destroyed', {value: true, enumerable: false, configurable: false});
}
isPlaceholder(path) {
const mount = this._getMount(path);
return mount && mount.placeholder;
}
isLocal(path, value) {
// eslint-disable-next-line no-shadow
const mount = this._getMount(path, false, mount => mount.local);
if (mount && mount.local) return true;
if (this._hasLocalProperties(mount, value)) {
throw new Error('Write on a mix of local and remote tree paths.');
}
return false;
}
_hasLocalProperties(mount, value) {
if (!mount) return false;
if (mount.local) return true;
if (!mount.localDescendants || !_.isObject(value)) return false;
for (const key in value) {
const local =
this._hasLocalProperties(mount.children[escapeKey(key)] || mount.children.$, value[key]);
if (local) return true;
}
return false;
}
forEachPlaceholderChild(path, iteratee) {
const mount = this._getMount(path);
_.forEach(mount && mount.children, child => {
if (child.placeholder) iteratee(child);
});
}
checkVueObject(object, path, checkedObjects) {
const top = !checkedObjects;
if (top) checkedObjects = [];
try {
for (const key of Object.getOwnPropertyNames(object)) {
if (RESERVED_VALUE_PROPERTY_NAMES[key] || Value.prototype.hasOwnProperty(key) ||
/^\$_/.test(key)) continue;
// eslint-disable-next-line no-shadow
const mount = this._findMount(mount => mount.Class === object.constructor);
if (mount && mount.matcher && _.includes(mount.matcher.variables, key)) continue;
let value;
try {
value = object[key];
} catch (e) {
// Ignore any values that hold exceptions, or otherwise throw on access -- we won't be
// able to check them anyway.
continue;
}
if (!(_.isArray(object) && (/\d+/.test(key) || key === 'length'))) {
const descriptor = Object.getOwnPropertyDescriptor(object, key);
if ('value' in descriptor || !descriptor.get) {
throw new Error(
`Value at ${path}, contained in a Firetruss object, has a rogue property: ${key}`);
}
if (object.$truss && descriptor.enumerable) {
try {
object[key] = value;
throw new Error(
`Firetruss object at ${path} has an enumerable non-Firebase property: ${key}`);
} catch (e) {
if (e.trussCode !== 'firebase_overwrite') throw e;
}
}
}
if (_.isObject(value) && !value.$$$trussCheck && Object.isExtensible(value) &&
!(_.isFunction(value) || value instanceof Promise)) {
value.$$$trussCheck = true;
checkedObjects.push(value);
this.checkVueObject(value, joinPath(path, escapeKey(key)), checkedObjects);
}
}
} finally {
if (top) {
for (const item of checkedObjects) delete item.$$$trussCheck;
}
}
}
}
function computeValue(prop, propertyStats) {
/* eslint-disable no-invalid-this */
if (this.$destroyed) return;
// Touch this object, since a failed access to a missing property doesn't get captured as a
// dependency.
this.$$touchThis();
const oldPropertyFrozen = currentPropertyFrozen;
currentPropertyFrozen = false;
const startTime = performanceNow();
let value;
try {
try {
value = prop.get.call(this);
} catch (e) {
value = new ErrorWrapper(e);
} finally {
propertyStats.runtime += performanceNow() - startTime;
propertyStats.numRecomputes += 1;
}
if (currentPropertyFrozen) value = new FrozenWrapper(value);
return value;
} finally {
currentPropertyFrozen = oldPropertyFrozen;
}
/* eslint-enable no-invalid-this */
}
function wrapConnections(object, connections) {
if (!connections || connections instanceof Handle) return connections;
return _.mapValues(connections, descriptor => {
if (descriptor instanceof Handle) return descriptor;
if (_.isFunction(descriptor)) {
const fn = function() {
/* eslint-disable no-invalid-this */
object.$$touchThis();
return wrapConnections(object, descriptor.call(this));
/* eslint-enable no-invalid-this */
};
fn.angularWatchSuppressed = true;
return fn;
}
return wrapConnections(object, descriptor);
});
}
function freeze(object) {
if (_.isNil(object) || !_.isObject(object) || Object.isFrozen(object) || object.$truss) {
return object;
}
object = Object.freeze(object);
if (_.isArray(object)) return _.map(object, value => freeze(value));
return _.mapValues(object, value => freeze(value));
}