UNPKG

causalityjs

Version:

A library for reactive programming based on Javascript proxies.

689 lines (576 loc) 22.6 kB
var addLiquidRepetitionFunctionality = function(liquid) { /********************************** * Dependency recording * * Upon change do **********************************/ // Debug var traceRepetition = true; // Recorder stack liquid.activeRecorders = []; var recorderId = 0; liquid.uponChangeDo = function() { // description(optional), doFirst, doAfterChange. doAfterChange cannot modify model, if needed, use a repeater instead. (for guaranteed consistency) // Arguments var doFirst; var doAfterChange; var description = null; if (arguments.length > 2) { description = arguments[0]; doFirst = arguments[1]; doAfterChange = arguments[2]; } else { doFirst = arguments[0]; doAfterChange = arguments[1]; } var recorder = { id : recorderId++, description : description, sources : [], sourcesDetails: [], uponChangeAction : doAfterChange }; liquid.activeRecorders.push(recorder); var returnValue = doFirst(); liquid.activeRecorders.pop(); return returnValue; }; var recordingPaused = 0; liquid.withoutRecording = function(action) { recordingPaused++; action(); recordingPaused--; }; liquid.registerObserverTo = function(object, definition, instance) { // instance can be a cached method if observing its return value, object & definition only needed for debugging. if (liquid.activeRecorders.length > 0 && recordingPaused === 0) { // stackDump(); // if (traceRepetition) { // console.log("registerObserverTo: " + object._ + "." + definition.name); // } // trace('repetition', "Observe: ", object, ".", definition.name); var activeRecorder = liquid.activeRecorders[liquid.activeRecorders.length - 1]; // console.log("Reading property " + object.__() + "." + instance + " with repeater " + activeRecorder.id); // Ensure observer structure in place (might be unecessary) if (typeof(instance.observers) === 'undefined') { // console.log("setting up observers..."); instance.observers = {}; } var observerSet = instance.observers; // Add repeater on object beeing observed, if not already added before var recorderId = activeRecorder.id; if (typeof(observerSet[recorderId]) === 'undefined') { trace('repetition', "Actually observe: ", object, ".", definition.name); observerSet[recorderId] = activeRecorder; // Note dependency in repeater itself (for cleaning up) activeRecorder.sources.push(observerSet); activeRecorder.sourcesDetails.push(object._ + "." + definition.name); // Debugging } // console.group("Just set up observation"); // console.log(activeRecorder.description); // console.log(Object.keys(instance.observers)); // console.log(instance); // console.groupEnd(); } }; /** ------------- * Upon change * -------------- */ var dirtyRecorders = []; liquid.observationBlocked = 0; liquid.blockUponChangeActions = function(callback) { liquid.observationBlocked++; callback(); liquid.observationBlocked--; if (liquid.observationBlocked == 0) { while (dirtyRecorders.length > 0) { var recorder = dirtyRecorders.shift() liquid.blockSideEffects(function() { traceGroup('repetition', "-- Upon change action --"); recorder.uponChangeAction(); traceGroupEnd(); }); } } }; // Recorders is a map from id => recorder liquid.recordersDirty = function(recorders) { for (id in recorders) { liquid.recorderDirty(recorders[id]); } }; liquid.recorderDirty = function(recorder) { trace('repetition', "Recorder noticed change: " + recorder.id + "." + recorder.description); traceGroup('repetition', "Dependencies"); recorder.sourcesDetails.forEach(function(source) { trace('repetition', source); }); traceGroupEnd(); liquid.removeObservation(recorder); // Cannot be any more dirty than it already is! if (liquid.observationBlocked > 0) { dirtyRecorders.push(recorder); } else { liquid.blockSideEffects(function() { traceGroup('repetition', "-- Upon change action --"); recorder.uponChangeAction(); traceGroupEnd(); }); } // if (traceRepetition) { // console.log("... recorder finished upon change action: " + recorder.id + "." + recorder.description); // } }; liquid.removeObservation = function(recorder) { // console.group("removeFromObservation: " + recorder.id + "." + recorder.description); if (recorder.id == 1) { // debugger; } // Clear out previous observations recorder.sources.forEach(function(observerSet) { // From observed object // console.log("Removing a source"); // console.log(observerSet[recorder.id]); delete observerSet[recorder.id]; }); clearArray(recorder.sources); // From repeater itself. // console.groupEnd(); }; /********************************** * * Repetition * **********************************/ liquid.isRefreshingRepeater = function() { return liquid.activeRepeaters.length > 0; }; liquid.activeRepeater = function() { return lastOfArray(liquid.activeRepeaters); }; // Debugging var dirtyRepeaters = []; var allRepeaters = []; // Repeater stack liquid.activeRepeaters = []; repeaterId = 0; liquid.repeatOnChange = function() { // description(optional), action // Arguments var repeaterAction; var description = ''; if (arguments.length > 1) { description = arguments[0]; repeaterAction = arguments[1]; } else { repeaterAction = arguments[0]; } var repeater = { id : repeaterId++, description : description, childRepeaters: [], removed : false, action : repeaterAction }; // Attatch to parent repeater. if (liquid.activeRepeaters.length > 0) { var parentRepeater = lastOfArray(liquid.activeRepeaters); parentRepeater.childRepeaters.push(repeater); }; // Debug // allRepeaters.push(repeater); // if (allRepeaters.length == 10) { // debugger; // } // console.log("repeatOnChange activated: " + repeater.id + "." + description); liquid.refreshRepeater(repeater); return repeater; }; liquid.refreshRepeater = function(repeater) { liquid.activeRepeaters.push(repeater); repeater.removed = false; repeater.returnValue = liquid.uponChangeDo( repeater.action, function() { liquid.unlockSideEffects(function() { // if (traceRepetition) { // console.log("Repeater's recorder notified change: " + repeater.id + "." + repeater.description); // } traceGroup('repetition', "Repeater dirty"); if (!repeater.removed) { liquid.repeaterDirty(repeater); } traceGroupEnd(); }); } ); liquid.activeRepeaters.pop(); }; liquid.repeaterDirty = function(repeater) { // TODO: Add update block on this stage? // if (traceRepetition) { // console.log("Repeater dirty: " + repeater.id + "." + repeater.description); // } liquid.removeSubRepeaters(repeater); dirtyRepeaters.push(repeater); liquid.refreshAllDirtyRepeaters(); }; liquid.removeSubRepeaters = function(repeater) { if (repeater.childRepeaters.length > 0) { repeater.childRepeaters.forEach(function(repeater) { liquid.removeRepeater(repeater); }); repeater.childRepeaters = []; } }; liquid.removeRepeater = function(repeater) { // console.log("removeRepeater: " + repeater.id + "." + repeater.description); repeater.removed = true; // In order to block any lingering recorder that triggers change if (repeater.childRepeaters.length > 0) { repeater.childRepeaters.forEach(function(repeater) { liquid.removeRepeater(repeater); }); repeater.childRepeaters.length = 0; } removeFromArray(repeater, dirtyRepeaters); removeFromArray(repeater, allRepeaters); }; var refreshingAllDirtyRepeaters = false; liquid.refreshAllDirtyRepeaters = function() { if (!refreshingAllDirtyRepeaters) { if (dirtyRepeaters.length > 0) { // if (traceRepetition) { // console.log("Starting refresh of all dirty repeaters, current count of dirty:" + dirtyRepeaters.length); // } traceGroup('repetition', "Starting refresh of all dirty repeaters, current count of dirty:" + dirtyRepeaters.length); refreshingAllDirtyRepeaters = true; while(dirtyRepeaters.length > 0) { var repeater = dirtyRepeaters.shift(); liquid.refreshRepeater(repeater); } refreshingAllDirtyRepeaters = false; traceGroupEnd(); // if (traceRepetition) { // console.log("Finished refresh of all dirty repeaters, current count of dirty:" + dirtyRepeaters.length + ", all current and refreshed repeaters:"); // console.log(allRepeaters); // // console.log("=============="); // } } } }; /************************************************************************ * Cached methods * * A cached method will not reevaluate for the same arguments, unless * some of the data it has read for such a call has changed. If there * is a parent cached method, it will be notified upon change. * (even if the parent does not actually use/read any return value) ************************************************************************/ function makeArgumentHash(argumentList) { var hash = ""; var first = true; argumentList.forEach(function(argument) { if (!first) { hash += ";"; } if (isArray(argument)) { hash += "[" + makeArgumentHash(argument) + "]"; } else if (typeof(argument) === 'object') { hash += "{id=" + argument._id + "}"; } else { hash += argument; } }); return hash; }; liquid.addGenericMethodCacher = function(object) { object['cachedCall'] = function() { // Split arguments var argumentsArray = argumentsToArray(arguments); var methodName = argumentsArray.shift(); var methodArguments = argumentsArray; // stackDump(); // console.log(this.__() + '.[cachedCall]' + methodName); traceGroup('repetition', this, '.[cachedCall]' + methodName); // Establish method caches if (typeof(this["__cachedCalls"]) === 'undefined') { this["__cachedCalls"] = {}; } var methodCaches = null; if (typeof(this.__cachedCalls[methodName]) === 'undefined') { methodCaches = {}; this.__cachedCalls[methodName] = methodCaches; } else { methodCaches = this.__cachedCalls[methodName]; } // Establish argument hash var argumentHash = makeArgumentHash(methodArguments); // console.log("Argument hash:" + argumentHash); if (typeof(methodCaches[argumentHash]) === 'undefined') { // console.log("Cached method not seen before, or re-caching needed... "); // trace('repitition', "Cached method not seen before, or re-caching needed... "); var methodCache = { observers : {}, returnValue : returnValue }; methodCaches[argumentHash] = methodCache; // Never encountered these arguments before, make a new cache var returnValue = liquid.uponChangeDo(this.__() + "." + methodName, function() { traceGroup('repetition', "Evlauate cached function"); var returnValue; liquid.blockSideEffects(function() { returnValue = this[methodName].apply(this, methodArguments); }.bind(this)); traceGroupEnd(); return returnValue; }.bind(this), function() { trace('repitition', "Terminating cached method repeater: ", this, '.[cachedCall]', methodName); // console.log("Terminating cached method repeater: " + this.__() + '.[cachedCall]' + methodName); // Get and delete method cache var methodCaches = this.__cachedCalls[methodName]; var methodCache = methodCaches[argumentHash]; delete methodCaches[argumentHash]; // Recorders dirty liquid.recordersDirty(methodCache.observers); }.bind(this)); methodCache.returnValue = returnValue; liquid.registerObserverTo(this, {name: methodName}, methodCache); traceGroupEnd(); return returnValue; } else { // Encountered these arguments before, reuse previous repeater // console.log("Cached method seen before ..."); // trace('repetition', "Cached method seen before ..."); var methodCache = methodCaches[argumentHash]; liquid.registerObserverTo(this, {name: methodName}, methodCache); traceGroupEnd(); return methodCache.returnValue; } } }; /********************************************************************************************************* * Infusion * *******************************************************************************************************/ liquid.activeInfusions = []; liquid.isInfusing = function() { return liquid.activeInfusions.length > 0; }; liquid.activeInfusion = function() { return lastOfArray(liquid.activeInfusions); }; function getInfusedObject(object) { } // Usage: // var infusion = {}; // Do not manipulate this state manually! Just keep it safe and send it with each call to infuse! // var changeCallback = function() { // return liquid.infuse(function() { // // ... create objects, // }, infusion); // }; liquid.setupInfusionState = function(infusion) { if (typeof(infusion.initialized) === 'undefined') { infusion.nextAutogeneratedInfusionId = 0; infusion.objectsToInfuse = []; infusion.temporaryInfusionIdToObjectMap = {}; infusion.establishedInfusionIdToObjectMap = {}; infusion.initialized = true; } }; liquid.infuse = function(infusion, action) { // Use action/infusion state map instead? liquid.activeInfusions.push(infusion); var returnValue; liquid.blockTrueSideEffects(function() { returnValue = this[methodName].apply(this, methodArguments); }); liquid.blockUponChangeActions(function() { // Build a final cacheId to object map that contains all objects present in the final infusion. var cacheIdToObjectMap = {}; for (cacheId in infusion.temporaryInfusionIdToObjectMap) { if (typeof(infusion.establishedInfusionIdToObjectMap[cacheId]) !== undefined) { // An established object exists, merge state. cacheIdToObjectMap[cacheId] = infusion.establishedInfusionIdToObjectMap[cacheId]; } else { // No established object exists. Need to re-map all outgoing references nevertheless. cacheIdToObjectMap[cacheId] = infusion.temporaryInfusionIdToObjectMap[cacheId]; } } // Find infusion objects that needs termination. This will ignore objects that are infused into the model directly. var objectsToTerminate = []; for (cacheId in infusion.establishedInfusionIdToObjectMap) { if (typeof(cacheIdToObjectMap[cacheId]) === 'undefined') { objectsToTerminate.push(infusion.establishedInfusionIdToObjectMap[cacheId]); } } // Merge state to established objects, and change references to established objects. infusion.objectsToInfuse.forEach(function(infusedObject) { function replaceReference(relatedObject) { if (relatedObject === null) { return null; } else if (typeof(relatedObject._infusion) !== 'undefined' && relatedObject._infusion === infusion) { if (typeof(object._cacheIdOrObject) === 'string') { return cacheIdToObjectMap[relatedObject.cacheId]; } else { return relatedObject._cacheIdOrObject; } } else { return relatedObject; } } var infusionTarget = replaceReference(infusedObject); if (infusionTarget !== null) { // Merge relations into established object infusedObject.forAllOutgoingRelations(function(definition, instance) { var newData = []; if (definition.isSet) { instance.data.forEach(function(relatedObject) { newData.push(replaceReference(relatedObject)); }); } else { newData = replaceReference(instance.data); } infusionTarget[definition.setter](newData); }); // Merge properties into established object infusedObject.forAllProperties(function(definition, instance) { // TODO: handle when data not set. How to transfer default value and unset the variable? Do we need an unset command? infusionTarget[definition.setterName](infusedObject[definition.getterName]()); }); } else { // Merge relations into established object infusedObject.forAllOutgoingRelations(function(definition, instance) { var newData; if (definition.isSet) { var newData = []; var temporaryData = instance.data; temporaryData.forEach(function(relatedObject) { newData.push(replaceReference(relatedObject)); }); infusedObject[definition.setter](newData) } else { var temporaryData = instance.data; var newData = replaceReference(relatedObject); } infusedObject[definition.setter](newData); }); } }); // Clear out removed objects TODO: Consider if we should remove all references to them also, and let them truly die!? Should it be possible to revive them again sometime? objectsToTerminate.forEach(function(object) { object.forAllOutgoingRelations(function(definition, instance) { if (definition.isSet) { object[definition.setterName]([]); } else { object[definition.setterName](null); } }); object.forAllProperties(function(definition, instance) { object[definition.setterName](definition.defaultValue); }); }); // Set the new established infusion id to object map. infusion.establishedInfusionIdToObjectMap = cacheIdToObjectMap; infusion.establishedInfusionIdToObjectMap = infusion.temporaryInfusionIdToObjectMap; infusion.temporaryInfusionIdToObjectMap = {}; returnValue = mapLiquidObjectsDeep(returnValue, replaceReference); // Get infused object; liquid.activeInfusions.pop(); }); return returnValue; }; /********************************************************************************************************* * Projections * * A infusion will maintain the identite(s) of it output ojbects. When something changes, the projection * will re-evaluate, and the result will be merged into the output data structure that was created in the * first run. * * Observers that read a projection will only be notified of change if there is an actual change in return-value (the idetitiy of a returned object) * Observers that read a projected data structure will only be notified of change if they read parts of * the projected data structure that has new merged data as the projection is updated. *******************************************************************************************************/ liquid.addGenericProjection = function(object) { object['project'] = function() { // Split arguments var argumentsArray = argumentsToArray(arguments); var methodName = argumentsArray.shift(); var methodArguments = argumentsArray; // Establish method caches if (typeof(this["__cachedProjections"]) === 'undefined') { this["__cachedProjections"] = {}; } var projections = null; if (typeof(this.__cachedProjections[methodName]) === 'undefined') { projections = {}; this.__cachedProjections[methodName] = projections; } else { projections = this.__cachedProjections[methodName]; } // Establish argument hash var argumentHash = makeArgumentHash(methodArguments); // console.log("Argument hash:" + argumentHash); if (typeof(projections[argumentHash]) === 'undefined') { console.log("Cached method not seen before, or re-caching needed... "); var projection = { returnValueObservers : {}, establishedReturnValue : null, infusion : {} }; projections[argumentHash] = projection; // Never encountered these arguments before, make a new cache projection.repeater = repeatOnChange(this.__() + "." + methodName, function() { console.log("Reevaluating projection: " + this.__() + '.[cachedCall]' + methodName); projection.temporaryProjectionIdToObjectMap = {}; // Run the projection code var newReturnValue; liquid.infuse(projection.infusion, function() { newReturnValue = this[methodName].apply(this, methodArguments); }); // Notify return value observers if (newReturnValue !== projection.establishedReturnValue) { projection.establishedReturnValue = newReturnValue; liquid.recordersDirty(projection.returnValueObservers); } }.bind(this) ); liquid.registerObserverTo(this, {name: methodName}, projection.returnValueObservers); return projection.establishedReturnValue; } else { // Encountered these arguments before, reuse previous repeater console.log("Cached method seen before ..."); var projection = projections[argumentHash]; liquid.registerObserverTo(this, {name: methodName}, projection.returnValueObservers); return projection.establishedReturnValue; } } } /********************************************************************************************************* * Block side effects *******************************************************************************************************/ liquid.activeSideEffectBlockers = []; liquid.isBlockingSideEffects = function() { return liquid.activeSideEffectBlockers.length > 0 && liquid.activeSideEffectBlocker() !== 'unlocked'; }; liquid.activeSideEffectBlocker = function() { return lastOfArray(liquid.activeSideEffectBlockers); }; liquid.unlockSideEffects = function(callback, repeater) { // Should only be used by repeater! liquid.activeSideEffectBlockers.push('unlocked'); callback(); liquid.activeSideEffectBlockers.pop(); }; liquid.blockAllSideEffects = function(callback) { liquid.activeSideEffectBlockers.push({ createdObjects: {}, // id -> It is ok to modify objects that have been created in this call, so we need to keep track of them doNotBlockOutgoingRelations: false // Newly created objects can not even refer to model objects, unless they do not have a reverse relation. }); callback(); liquid.activeSideEffectBlockers.pop(); }; liquid.blockTrueSideEffects = function(callback) { liquid.activeSideEffectBlockers.push({ createdObjects: {}, // id -> It is ok to modify objects that have been created in this call, so we need to keep track of them doNotBlockOutgoingRelations: true // Newly created objects can refer to model objects. }); callback(); liquid.activeSideEffectBlockers.pop(); }; liquid.blockSideEffects = liquid.blockTrueSideEffects; }; if (typeof(module) !== 'undefined' && typeof(module.exports) !== 'undefined') { module.exports.addLiquidRepetitionFunctionality = addLiquidRepetitionFunctionality; }