UNPKG

firetruss

Version:

Advanced data sync layer for Firebase and Vue.js

724 lines (649 loc) 24.9 kB
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'; // These are defined separately for each object so they're not included in Value below. const RESERVED_VALUE_PROPERTY_NAMES = {__ob__: true}; const UNSUPPORTED_LIFECYCLE_METHODS = new Set([ 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'activated', 'deactivated', 'errorCaptured' ]); const UNSUPPORTED_LIFECYCLE_HOOKS = new Set(_.map(UNSUPPORTED_LIFECYCLE_METHODS, method => `hook:${method}`)); const LAST_COMPUTED_VALUE = Symbol('last-computed-value'); // 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 $info() {return this.$truss.info;} 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(); this.$off('hook:destroyed', uninterceptAndRemoveFinalizer); }; this.$on('hook:destroyed', 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 = () => { this.$off('hook:destroyed', destroy); return originalDestroy.call(connector); }; this.$on('hook:destroyed', 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), () => {this.$off('hook:destroyed', promise.cancel);} ); this.$on('hook:destroyed', 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, vm: this}); unobserveAndRemoveFinalizer = () => { // eslint-disable-line prefer-const unobserve(); this.$off('hook:destroyed', unobserveAndRemoveFinalizer); }; this.$on('hook:destroyed', 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, () => {this.$off('hook:destroyed', promise.cancel);}); this.$on('hook:destroyed', promise.cancel); return promise; } } 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, () => {this.$off('hook:destroyed', promise.cancel);}); this.$on('hook:destroyed', promise.cancel); return promise; } $freezeComputedProperty() { if (!_.isBoolean(currentPropertyFrozen)) { throw new Error('Cannot freeze a computed property outside of its getter function'); } currentPropertyFrozen = true; } get $lastComputedValue() { if (!_.isBoolean(currentPropertyFrozen)) { throw new Error( 'Cannot use last computed value of a property outside of its getter function'); } return LAST_COMPUTED_VALUE; } $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) { (Object.hasOwn(this.$parent, '$data') ? this.$parent.$data : this.$parent)[this.$key]; } else { this.$store; } /* eslint-enable no-unused-expressions */ } get $destroyed() { // eslint-disable-line lodash/prefer-constant return false; } $on(event, callback) { if (this.$destroyed) throw new Error('Object already destroyed'); if (UNSUPPORTED_LIFECYCLE_HOOKS.has(event)) { throw new Error(`Models don't support the "${event}" lifecycle event`); } (this.$$hooks[event] = this.$$hooks[event] || []).push(callback); return this; } $once(event, callback) { const object = this; function cb(...args) { object.$off(event, cb); callback(...args); } cb.fn = callback; return this.$on(event, cb); } $off(event, callback) { if (event) { if (callback) { if (_.isArray(event)) { for (const ev of event) this.$off(ev, callback); } else if (this.$$hooks[event]) { const callbacks = this.$$hooks[event]; for (let i = 0; i < callbacks.length; i++) { const cb = callbacks[i]; if (cb === callback || cb.fn === callback) { callbacks.splice(i, 1); break; } } } } else { delete this.$$hooks[event]; } } else { for (const key of _.keys(this.$$hooks)) delete this.$$hooks[key]; } return this; } $emit(event, ...args) { if (_.has(this, '$$hooks')) { // Some callbacks remove themselves from the array, so clone it before iterating. _.forEach(_.clone(this.$$hooks[event]), callback => { if (callback.$once && callback.$once[event]) { callback.$once[event] -= 1; this.$off(event, callback); } callback(...args); }); } return this; } get $$hooks() { Object.defineProperty(this, '$$hooks', { value: {}, writable: false, enumerable: false, configurable: false }); return this.$$hooks; } } 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(vue, debug) { this._vue = vue; 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() {/* empty */} _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 (_.isEqual(descriptor, Object.getOwnPropertyDescriptor(Value.prototype, name))) { continue; } throw new Error(`Property names starting with "$" are reserved: ${Class.name}.${name}`); } if (UNSUPPORTED_LIFECYCLE_METHODS.has(name) && _.isFunction(proto[name])) { throw new Error(`Models don't support the "${name}" lifecycle method`); } 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' || Object.hasOwn(Class.prototype, 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')); }); _(allVariables).uniq().forEach(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; const initialize = () => { let unwatchNow = false; const compute = computeValue.bind(object, prop, propertyStats); compute.toString = _.constant(`compute ${prop.fullName}`); let unwatch = () => {unwatchNow = true;}; unwatch = this._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(this._vue._watchers || this._vue._scope.effects); watcher.id = -watcher.id; function update(newValue) { const startTime = performance.now(); if (newValue instanceof FrozenWrapper) { newValue = newValue.value; unwatch(); object.$off('hook:destroyed', unwatch); } if (newValue === LAST_COMPUTED_VALUE || isTrussEqual(value, newValue)) return; // console.log('updating', object.$key, prop.fullName, 'from', value, 'to', newValue); 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); propertyStats.numUpdates += 1; propertyStats.updateTime += performance.now() - startTime; return true; } if (unwatchNow) { unwatch(); } else { object.$on('hook:destroyed', unwatch); } object.$off('hook:created', initialize); }; object.$on('hook:created', initialize); 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) { Object.defineProperty( object, '$destroyed', {value: true, enumerable: false, configurable: false}); } emitLifecycleHook(object, hook) { if (_.isFunction(object[hook])) object[hook](); object.$emit(`hook:${hook}`); } 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 = new Set(); const objectPropertyValues = new Map(); const mount = this._findMount(candidate => candidate.Class === object.constructor); const targetProperties = _(object) .thru(Object.getOwnPropertyNames) .reject(key => RESERVED_VALUE_PROPERTY_NAMES[key] || Object.hasOwn(Value.prototype, key) || /^\$_/.test(key) ) .reject(key => mount && mount.matcher && _.includes(mount.matcher.variables, key)) .map(key => { let value; try { value = object[key]; // Ignore builtin object types. if (value instanceof RegExp) return; } catch { // Ignore any values that hold exceptions, or otherwise throw on access -- we won't be // able to check them anyway. return; } const descriptor = Object.getOwnPropertyDescriptor(object, key); const computed = !descriptor.enumerable && descriptor.set && !Object.hasOwn(object, '$_' + key); return {key, value, descriptor, computed}; }) .compact() .value(); for (const {key, value, descriptor, computed} of targetProperties) { if (!(_.isArray(object) && (/^\d+$/.test(key) || key === 'length'))) { 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)) { if (!checkedObjects.has(value) && !Object.isSealed(value) && !(_.isFunction(value) || _.isElement(value) || value instanceof Promise)) { checkedObjects.add(value); this.checkVueObject(value, joinPath(path, escapeKey(key)), checkedObjects); } if (!computed && !value.$truss) objectPropertyValues.set(value, key); } } for (const {key, value, computed} of targetProperties) { if (computed && _.isObject(value) && !value.$truss) { const otherKey = objectPropertyValues.get(value); if (otherKey) { throw new Error( `Firetruss object at ${path} has properties ${key} ` + `and ${otherKey} with an aliased value`); } } } } } 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 = performance.now(); let value; try { try { value = prop.get.call(this); } catch (e) { value = new ErrorWrapper(e); } finally { propertyStats.computeTime += performance.now() - 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; if (_.isFunction(connections)) { const fn = function() { /* eslint-disable no-invalid-this */ object.$$touchThis(); return wrapConnections(object, connections.call(this)); /* eslint-enable no-invalid-this */ }; fn.angularWatchSuppressed = true; return fn; } return _.mapValues(connections, descriptor => 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)); }