firetruss
Version:
Advanced data sync layer for Firebase and Vue.js
716 lines (660 loc) • 27.7 kB
JavaScript
import angular from './angularCompatibility.js';
import Coupler from './Coupler.js';
import Modeler from './Modeler.js';
import Reference from './Reference.js';
import {escapeKey, escapeKeys, unescapeKey, joinPath, splitPath} from './utils/paths.js';
import {wrapPromiseCallback, promiseFinally} from './utils/promises.js';
import {SERVER_TIMESTAMP} from './utils/utils.js';
import _ from 'lodash';
import Vue from 'vue';
class Transaction {
constructor(ref) {
this._ref = ref;
this._outcome = undefined;
this._values = undefined;
}
get currentValue() {return this._ref.value;}
get outcome() {return this._outcome;}
get values() {return this._values;}
_setOutcome(value) {
if (this._outcome) throw new Error('Transaction already resolved with ' + this._outcome);
this._outcome = value;
}
abort() {
this._setOutcome('abort');
}
cancel() {
this._setOutcome('cancel');
}
set(value) {
if (value === undefined) throw new Error('Invalid argument: undefined');
this._setOutcome('set');
this._values = {'': value};
}
update(values) {
if (values === undefined) throw new Error('Invalid argument: undefined');
if (_.isEmpty(values)) return this.cancel();
this._setOutcome('update');
this._values = values;
}
}
export default class Tree {
constructor(truss, rootUrl, bridge, dispatcher) {
this._truss = truss;
this._rootUrl = rootUrl;
this._bridge = bridge;
this._dispatcher = dispatcher;
this._firebasePropertyEditAllowed = false;
this._writeSerial = 0;
this._localWrites = {};
this._localWriteTimestamp = null;
this._initialized = false;
this._modeler = new Modeler(truss.constructor.VERSION === 'dev');
this._coupler = new Coupler(
rootUrl, bridge, dispatcher, this._integrateSnapshot.bind(this), this._prune.bind(this));
this._vue = new Vue({data: {$root: undefined}});
Object.seal(this);
// Call this.init(classes) to complete initialization; we need two phases so that truss can bind
// the tree into its own accessors prior to defining computed functions, which may try to
// access the tree root via truss.
}
get root() {
if (!this._vue.$data.$root) {
this._vue.$data.$root = this._createObject('/');
this._fixObject(this._vue.$data.$root);
this._completeCreateObject(this._vue.$data.$root);
angular.digest();
}
return this._vue.$data.$root;
}
get truss() {
return this._truss;
}
init(classes) {
if (this._initialized) {
throw new Error('Data objects already created, too late to mount classes');
}
this._initialized = true;
this._modeler.init(classes, !this._vue.$data.$root);
const createdObjects = [];
this._plantPlaceholders(this.root, '/', undefined, createdObjects);
for (const object of createdObjects) this._completeCreateObject(object);
}
destroy() {
this._coupler.destroy();
if (this._modeler) this._modeler.destroy();
this._vue.$destroy();
}
connectReference(ref, method) {
this._checkHandle(ref);
const operation = this._dispatcher.createOperation('read', method, ref);
let unwatch;
operation._disconnect = this._disconnectReference.bind(this, ref, operation, unwatch);
this._dispatcher.begin(operation).then(() => {
if (operation.running && !operation._disconnected) {
this._coupler.couple(ref.path, operation);
operation._coupled = true;
}
}).catch(_.noop); // ignore exception, let onFailure handlers deal with it
return operation._disconnect;
}
_disconnectReference(ref, operation, unwatch, error) {
if (operation._disconnected) return;
operation._disconnected = true;
if (unwatch) unwatch();
if (operation._coupled) {
this._coupler.decouple(ref.path, operation); // will call back to _prune if necessary
operation._coupled = false;
}
this._dispatcher.end(operation, error).catch(_.noop);
}
isReferenceReady(ref) {
this._checkHandle(ref);
return this._coupler.isSubtreeReady(ref.path);
}
connectQuery(query, keysCallback, method) {
this._checkHandle(query);
const operation = this._dispatcher.createOperation('read', method, query);
operation._disconnect = this._disconnectQuery.bind(this, query, operation);
this._dispatcher.begin(operation).then(() => {
if (operation.running && !operation._disconnected) {
this._coupler.subscribe(query, operation, keysCallback);
operation._coupled = true;
}
}).catch(_.noop); // ignore exception, let onFailure handlers deal with it
return operation._disconnect;
}
_disconnectQuery(query, operation, error) {
if (operation._disconnected) return;
operation._disconnected = true;
if (operation._coupled) {
this._coupler.unsubscribe(query, operation); // will call back to _prune if necessary
operation._coupled = false;
}
this._dispatcher.end(operation, error).catch(_.noop);
}
isQueryReady(query) {
return this._coupler.isQueryReady(query);
}
_checkHandle(handle) {
if (!handle.belongsTo(this._truss)) {
throw new Error('Reference belongs to another Truss instance');
}
}
throttleRemoteDataUpdates(delay) {
this._coupler.throttleSnapshots(delay);
}
update(ref, method, values) {
values = _.mapValues(values, value => escapeKeys(value));
const numValues = _.size(values);
if (!numValues) return Promise.resolve();
if (method === 'update' || method === 'override') {
checkUpdateHasOnlyDescendantsWithNoOverlap(ref.path, values);
}
if (this._applyLocalWrite(values, method === 'override')) return Promise.resolve();
const pathPrefix = extractCommonPathPrefix(values);
relativizePaths(pathPrefix, values);
if (pathPrefix !== ref.path) ref = new Reference(ref._tree, pathPrefix, ref._annotations);
const url = this._rootUrl + pathPrefix;
const writeSerial = this._writeSerial;
const set = numValues === 1;
const operand = set ? values[''] : values;
return this._dispatcher.execute('write', set ? 'set' : 'update', ref, operand, () => {
const promise = this._bridge[set ? 'set' : 'update'](url, operand, writeSerial);
return promise.catch(e => {
if (!e.immediateFailure) return Promise.reject(e);
return promiseFinally(this._repair(ref, values), () => Promise.reject(e));
});
});
}
commit(ref, updateFunction) {
let tries = 0;
updateFunction = wrapPromiseCallback(updateFunction);
const attemptTransaction = () => {
if (tries++ >= 25) {
return Promise.reject(new Error('Transaction needed too many retries, giving up'));
}
const txn = new Transaction(ref);
let oldValue;
// Ensure that Vue's watcher queue gets emptied and computed properties are up to date before
// running the updateFunction.
return Vue.nextTick().then(() => {
oldValue = toFirebaseJson(txn.currentValue);
return updateFunction(txn);
}).then(() => {
if (!_.isEqual(oldValue, toFirebaseJson(txn.currentValue))) return attemptTransaction();
if (txn.outcome === 'abort') return txn; // early return to save time
const values = _.mapValues(txn.values, value => escapeKeys(value));
switch (txn.outcome) {
case 'cancel':
break;
case 'set':
if (this._applyLocalWrite({[ref.path]: values['']})) return Promise.resolve();
break;
case 'update':
checkUpdateHasOnlyDescendantsWithNoOverlap(ref.path, values);
if (this._applyLocalWrite(values)) return Promise.resolve();
relativizePaths(ref.path, values);
break;
default:
throw new Error('Invalid transaction outcome: ' + (txn.outcome || 'none'));
}
return this._bridge.transaction(
this._rootUrl + ref.path, oldValue, values, this._writeSerial
).then(result => {
_.forEach(result.snapshots, snapshot => this._integrateSnapshot(snapshot));
return result.committed ? txn : attemptTransaction();
}, e => {
if (e.immediateFailure && (txn.outcome === 'set' || txn.outcome === 'update')) {
return promiseFinally(this._repair(ref, values), () => Promise.reject(e));
}
return Promise.reject(e);
});
});
};
return this._truss.peek(ref, () => {
return this._dispatcher.execute('write', 'commit', ref, undefined, attemptTransaction);
});
}
_repair(ref, values) {
// If a write fails early -- that is, before it gets applied to the Firebase client's local
// tree -- then we need to repair our own local tree manually since Firebase won't send events
// to unwind the change. This should be very rare since it's always due to a developer mistake
// so we don't need to be particularly efficient.
const basePath = ref.path;
const paths = _(values).keys().flatMap(key => {
let path = basePath;
if (key) path = joinPath(path, key);
return _.keys(this._coupler.findCoupledDescendantPaths(path));
}).value();
return Promise.all(_.map(paths, path => {
return this._bridge.once(this._rootUrl + path).then(snap => {
this._integrateSnapshot(snap);
});
}));
}
_applyLocalWrite(values, override) {
// TODO: correctly apply local writes that impact queries. Currently, a local write will update
// any objects currently selected by a query, but won't add or remove results.
this._writeSerial++;
this._localWriteTimestamp = this._truss.now;
const createdObjects = [];
let numLocal = 0;
_.forEach(values, (value, path) => {
const local = this._modeler.isLocal(path, value);
if (local) numLocal++;
const coupledDescendantPaths =
local ? {[path]: true} : this._coupler.findCoupledDescendantPaths(path);
if (_.isEmpty(coupledDescendantPaths)) return;
const offset = (path === '/' ? 0 : path.length) + 1;
for (const descendantPath in coupledDescendantPaths) {
const subPath = descendantPath.slice(offset);
let subValue = value;
if (subPath && value !== null && value !== undefined) {
for (const segment of splitPath(subPath)) {
subValue = subValue.$data[segment];
if (subValue === undefined) break;
}
}
if (_.isNil(subValue)) {
this._prune(descendantPath);
} else {
const key = _.last(splitPath(descendantPath));
this._plantValue(
descendantPath, key, subValue,
this._scaffoldAncestors(descendantPath, false, createdObjects), false, override, local,
createdObjects
);
}
if (!override && !local) this._localWrites[descendantPath] = this._writeSerial;
}
});
for (const object of createdObjects) this._completeCreateObject(object);
if (numLocal && numLocal < _.size(values)) {
throw new Error('Write on a mix of local and remote tree paths.');
}
return override || !!numLocal;
}
/**
* 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, so you should call _completeCreateObject once it's done so and
* before any Firebase properties are added.
*/
_createObject(path, parent) {
if (!this._initialized && path !== '/') this.init();
const properties = {
// We want Vue to wrap this; we'll make it non-enumerable in _fixObject.
$parent: {value: parent, configurable: true, enumerable: true},
$path: {value: path}
};
if (path === '/') properties.$truss = {value: this._truss};
const object = this._modeler.createObject(path, properties);
Object.defineProperties(object, properties);
return object;
}
// To be called on the result of _createObject after it's been inserted into the _vue hierarchy
// and Vue has had a chance to initialize it.
_fixObject(object) {
for (const name of Object.getOwnPropertyNames(object)) {
const descriptor = Object.getOwnPropertyDescriptor(object, name);
if (descriptor.configurable && descriptor.enumerable) {
descriptor.enumerable = false;
if (_.startsWith(name, '$')) descriptor.configurable = false;
Object.defineProperty(object, name, descriptor);
}
}
}
// To be called on the result of _createObject after _fixObject, and after any additional Firebase
// properties have been set, to run initialiers.
_completeCreateObject(object) {
if (object.hasOwnProperty('$$initializers')) {
for (const fn of object.$$initializers) fn(this._vue);
delete object.$$initializers;
}
}
_destroyObject(object) {
if (!(object && object.$truss) || object.$destroyed) return;
this._modeler.destroyObject(object);
// Normally we'd only destroy enumerable children, which are the Firebase properties. However,
// clients have the option of creating hidden placeholders, so we need to scan non-enumerable
// properties as well. To distinguish such placeholders from the myriad other non-enumerable
// properties (that lead all over tree, e.g. $parent), we check that the property's parent is
// ourselves before destroying.
for (const key of Object.getOwnPropertyNames(object.$data)) {
const child = object.$data[key];
if (child && child.$parent === object) this._destroyObject(child);
}
}
_integrateSnapshot(snap) {
_.forEach(this._localWrites, (writeSerial, path) => {
if (snap.writeSerial >= writeSerial) delete this._localWrites[path];
});
if (snap.exists) {
const createdObjects = [];
const parent = this._scaffoldAncestors(snap.path, true, createdObjects);
if (parent) {
this._plantValue(
snap.path, snap.key, snap.value, parent, true, false, false, createdObjects);
}
for (const object of createdObjects) this._completeCreateObject(object);
} else {
this._prune(snap.path, null, true);
}
}
_scaffoldAncestors(path, remoteWrite, createdObjects) {
let object;
const segments = _.dropRight(splitPath(path, true));
let ancestorPath = '/';
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const key = unescapeKey(segment);
let child = segment ? object.$data[key] : this.root;
if (segment) ancestorPath += (ancestorPath === '/' ? '' : '/') + segment;
if (child) {
if (remoteWrite && this._localWrites[ancestorPath]) return;
} else {
child = this._plantValue(
ancestorPath, key, {}, object, remoteWrite, false, false, createdObjects);
if (!child) return;
}
object = child;
}
return object;
}
_plantValue(path, key, value, parent, remoteWrite, override, local, createdObjects) {
if (remoteWrite && _.isNil(value)) {
throw new Error(`Snapshot includes invalid value at ${path}: ${value}`);
}
if (remoteWrite && this._localWrites[path || '/']) return;
if (_.isEqual(value, SERVER_TIMESTAMP)) value = this._localWriteTimestamp;
let object = parent.$data[key];
if (!_.isArray(value) && !(local ? _.isPlainObject(value) : _.isObject(value))) {
this._destroyObject(object);
if (!local && _.isNil(value)) {
this._deleteFirebaseProperty(parent, key);
} else {
this._setFirebaseProperty(parent, key, value);
}
return;
}
let objectCreated = false;
if (!_.isObject(object)) {
// Need to pre-set the property, so that if the child object attempts to watch any of its own
// properties while being created the $$touchThis method has something to add a dependency on
// as the object's own properties won't be made reactive until *after* it's been created.
this._setFirebaseProperty(parent, key, null);
object = this._createObject(path, parent);
this._setFirebaseProperty(parent, key, object, object.$hidden);
this._fixObject(object);
createdObjects.push(object);
objectCreated = true;
}
if (override) {
Object.defineProperty(object, '$overridden', {get: _.constant(true), configurable: true});
} else if (object.$overridden) {
delete object.$overridden;
}
// Plant hidden placeholders first, so their computed watchers will have a similar precedence to
// the parent object, and the parent object's other children will get computed first. This can
// optimize updates when parts of a complex model are broken out into hidden sub-models, and
// shouldn't risk being overwritten by actual Firebase data since that will rarely (never?) be
// hidden.
if (objectCreated) this._plantPlaceholders(object, path, true, createdObjects);
_.forEach(value, (item, escapedChildKey) => {
this._plantValue(
joinPath(path, escapedChildKey), unescapeKey(escapedChildKey), item, object, remoteWrite,
override, local, createdObjects
);
});
if (objectCreated) {
this._plantPlaceholders(object, path, false, createdObjects);
} else {
_.forEach(object.$data, (item, childKey) => {
const escapedChildKey = escapeKey(childKey);
if (!value.hasOwnProperty(escapedChildKey)) {
this._prune(joinPath(path, escapedChildKey), null, remoteWrite);
}
});
}
return object;
}
_plantPlaceholders(object, path, hidden, createdObjects) {
this._modeler.forEachPlaceholderChild(path, mount => {
if (hidden !== undefined && hidden !== !!mount.hidden) return;
const key = unescapeKey(mount.escapedKey);
if (!object.$data.hasOwnProperty(key)) {
this._plantValue(
joinPath(path, mount.escapedKey), key, mount.placeholder, object, false, false, false,
createdObjects);
}
});
}
_prune(path, lockedDescendantPaths, remoteWrite) {
lockedDescendantPaths = lockedDescendantPaths || {};
const object = this.getObject(path);
if (object === undefined) return;
if (remoteWrite && this._avoidLocalWritePaths(path, lockedDescendantPaths)) return;
if (!(_.isEmpty(lockedDescendantPaths) && this._pruneAncestors(path, object)) &&
_.isObject(object)) {
// The target object is a placeholder, and all ancestors are placeholders or otherwise needed
// as well, so we can't delete it. Instead, dive into its descendants to delete what we can.
this._pruneDescendants(object, lockedDescendantPaths);
}
}
_avoidLocalWritePaths(path, lockedDescendantPaths) {
for (const localWritePath in this._localWrites) {
if (!this._localWrites.hasOwnProperty(localWritePath)) continue;
if (path === localWritePath || localWritePath === '/' ||
_.startsWith(path, localWritePath + '/')) return true;
if (path === '/' || _.startsWith(localWritePath, path + '/')) {
const segments = splitPath(localWritePath, true);
for (let i = segments.length; i > 0; i--) {
const subPath = segments.slice(0, i).join('/');
const active = i === segments.length;
if (lockedDescendantPaths[subPath] || lockedDescendantPaths[subPath] === active) break;
lockedDescendantPaths[subPath] = active;
if (subPath === path) break;
}
}
}
}
_pruneAncestors(targetPath, targetObject) {
// Destroy the child (unless it's a placeholder that's still needed) and any ancestors that
// are no longer needed to keep this child rooted, and have no other reason to exist.
let deleted = false;
let object = targetObject;
// The target object may be a primitive, in which case it won't have $path, $parent and $key
// properties. In that case, use the target path to figure those out instead. Note that all
// ancestors of the target object will necessarily not be primitives and will have those
// properties.
let targetKey;
const targetParentPath = targetPath.replace(/\/[^/]+$/, match => {
targetKey = unescapeKey(match.slice(1));
return '';
});
while (object !== undefined && object !== this.root) {
const parent =
object && object.$parent || object === targetObject && this.getObject(targetParentPath);
if (!this._modeler.isPlaceholder(object && object.$path || targetPath)) {
const ghostObjects = deleted ? null : [targetObject];
if (!this._holdsConcreteData(object, ghostObjects)) {
deleted = true;
this._deleteFirebaseProperty(
parent, object && object.$key || object === targetObject && targetKey);
}
}
object = parent;
}
return deleted;
}
_holdsConcreteData(object, ghostObjects) {
if (_.isNil(object)) return false;
if (ghostObjects && _.includes(ghostObjects, object)) return false;
if (!_.isObject(object) || !object.$truss) return true;
return _.some(object.$data, value => this._holdsConcreteData(value, ghostObjects));
}
_pruneDescendants(object, lockedDescendantPaths) {
if (lockedDescendantPaths[object.$path]) return true;
if (object.$overridden) delete object.$overridden;
let coupledDescendantFound = false;
_.forEach(object.$data, (value, key) => {
let shouldDelete = true;
let valueLocked;
if (lockedDescendantPaths[joinPath(object.$path, escapeKey(key))]) {
shouldDelete = false;
valueLocked = true;
} else if (!_.isNil(value) && value.$truss) {
const placeholder = this._modeler.isPlaceholder(value.$path);
if (placeholder || _.has(lockedDescendantPaths, value.$path)) {
valueLocked = this._pruneDescendants(value, lockedDescendantPaths);
shouldDelete = !placeholder && !valueLocked;
}
}
if (shouldDelete) this._deleteFirebaseProperty(object, key);
coupledDescendantFound = coupledDescendantFound || valueLocked;
});
return coupledDescendantFound;
}
getObject(path) {
const segments = splitPath(path);
let object;
for (const segment of segments) {
object = segment ? object.$data[segment] : this.root;
if (object === undefined) return;
}
return object;
}
_getFirebasePropertyDescriptor(object, data, key) {
const descriptor = Object.getOwnPropertyDescriptor(data, key);
if (descriptor) {
if (!descriptor.enumerable) {
const child = data[key];
if (!child || child.$parent !== object) {
throw new Error(
`Key conflict between Firebase and instance or computed properties at ` +
`${object.$path}: ${key}`);
}
}
if (!descriptor.get || !descriptor.set) {
throw new Error(`Unbound property at ${object.$path}: ${key}`);
}
} else if (key in data) {
throw new Error(
`Key conflict between Firebase and inherited property at ${object.$path}: ${key}`);
}
return descriptor;
}
_setFirebaseProperty(object, key, value, hidden) {
const data = object.hasOwnProperty('$data') ? object.$data : object;
let descriptor = this._getFirebasePropertyDescriptor(object, data, key);
if (descriptor) {
if (hidden) {
// Redefine property as hidden after it's been created, since we usually don't know whether
// it should be hidden until too late. This is a one-way deal -- you can't unhide a
// property later, but that's fine for our purposes.
Object.defineProperty(data, key, {
get: descriptor.get, set: descriptor.set, configurable: true, enumerable: false
});
}
if (data[key] === value) return;
this._firebasePropertyEditAllowed = true;
data[key] = value;
this._firebasePropertyEditAllowed = false;
} else {
Vue.set(data, key, value);
descriptor = Object.getOwnPropertyDescriptor(data, key);
Object.defineProperty(data, key, {
get: descriptor.get, set: this._overwriteFirebaseProperty.bind(this, descriptor, key),
configurable: true, enumerable: !hidden
});
}
angular.digest();
}
_overwriteFirebaseProperty(descriptor, key, newValue) {
if (!this._firebasePropertyEditAllowed) {
const e = new Error(`Firebase data cannot be mutated directly: ${key}`);
e.trussCode = 'firebase_overwrite';
throw e;
}
descriptor.set.call(this, newValue);
}
_deleteFirebaseProperty(object, key) {
const data = object.hasOwnProperty('$data') ? object.$data : object;
// Make sure it's actually a Firebase property.
this._getFirebasePropertyDescriptor(object, data, key);
this._destroyObject(data[key]);
Vue.delete(data, key);
angular.digest();
}
checkVueObject(object, path) {
this._modeler.checkVueObject(object, path);
}
}
export function checkUpdateHasOnlyDescendantsWithNoOverlap(rootPath, values) {
// First, check all paths for correctness and absolutize them, since there could be a mix of
// absolute paths and relative keys.
_.forEach(_.keys(values), path => {
if (path.charAt(0) === '/') {
if (!(path === rootPath || rootPath === '/' ||
_.startsWith(path, rootPath + '/') && path.length > rootPath.length + 1)) {
throw new Error(`Update item is not a descendant of target ref: ${path}`);
}
} else {
if (_.includes(path, '/')) {
throw new Error(`Update item deep path must be absolute, taken from a reference: ${path}`);
}
const absolutePath = joinPath(rootPath, escapeKey(path));
if (values.hasOwnProperty(absolutePath)) {
throw new Error(`Update items overlap: ${path} and ${absolutePath}`);
}
values[absolutePath] = values[path];
delete values[path];
}
});
// Then check for overlaps;
const allPaths = _(values).keys().map(path => joinPath(path, '')).sortBy('length').value();
_.forEach(values, (value, path) => {
for (const otherPath of allPaths) {
if (otherPath.length > path.length) break;
if (path !== otherPath && _.startsWith(path, otherPath)) {
throw new Error(`Update items overlap: ${otherPath} and ${path}`);
}
}
});
}
export function extractCommonPathPrefix(values) {
let prefixSegments;
_.forEach(values, (value, path) => {
const segments = path === '/' ? [''] : splitPath(path, true);
if (prefixSegments) {
let firstMismatchIndex = 0;
const maxIndex = Math.min(prefixSegments.length, segments.length);
while (firstMismatchIndex < maxIndex &&
prefixSegments[firstMismatchIndex] === segments[firstMismatchIndex]) {
firstMismatchIndex++;
}
prefixSegments = prefixSegments.slice(0, firstMismatchIndex);
if (!prefixSegments.length) return false;
} else {
prefixSegments = segments;
}
});
return prefixSegments.length === 1 ? '/' : prefixSegments.join('/');
}
export function relativizePaths(rootPath, values) {
const offset = rootPath === '/' ? 1 : rootPath.length + 1;
_.forEach(_.keys(values), path => {
values[path.slice(offset)] = values[path];
delete values[path];
});
}
export function toFirebaseJson(object) {
if (!_.isObject(object)) return object;
const result = {};
const data = object.$data;
for (const key in data) {
if (data.hasOwnProperty(key)) result[escapeKey(key)] = toFirebaseJson(data[key]);
}
return result;
}