define("dstore/Trackable", [ 'dojo/_base/lang', 'dojo/_base/declare', 'dojo/aspect', 'dojo/when', 'dojo/promise/all', 'dojo/_base/array', 'dojo/on' /*=====, './api/Store' =====*/ ], function (lang, declare, aspect, when, whenAll, arrayUtil, on /*=====, Store =====*/) { // module: // dstore/Trackable var revision = 0; function createRange(newStart, newEnd) { return { start: newStart, count: newEnd - newStart }; } function registerRange(ranges, newStart, newEnd) { for (var i = ranges.length - 1; i >= 0; --i) { var existingRange = ranges[i], existingStart = existingRange.start, existingEnd = existingStart + existingRange.count; if (newStart > existingEnd) { // existing range completely precedes new range. we are done. ranges.splice(i + 1, 0, createRange(newStart, newEnd)); return; } else if (newEnd >= existingStart) { // the ranges overlap and must be merged into a single range newStart = Math.min(newStart, existingStart); newEnd = Math.max(newEnd, existingEnd); ranges.splice(i, 1); } } ranges.unshift(createRange(newStart, newEnd)); } function unregisterRange(ranges, start, end) { for (var i = 0, range; (range = ranges[i]); ++i) { var existingStart = range.start, existingEnd = existingStart + range.count; if (start <= existingStart) { if (end >= existingEnd) { // The existing range is within the forgotten range ranges.splice(i, 1); } else { // The forgotten range overlaps the beginning of the existing range range.start = end; range.count = existingEnd - range.start; // Since the forgotten range ends before the existing range, // there are no more ranges to update, and we are done return; } } else if (start < existingEnd) { if (end > existingStart) { // The forgotten range is within the existing range ranges.splice(i, 1, createRange(existingStart, start), createRange(end, existingEnd)); // We are done because the existing range bounded the forgotten range return; } else { // The forgotten range overlaps the end of the existing range range.count = start - range.start; } } } } var trackablePrototype = { track: function () { var store = this.store || this; // monitor for updates by listening to these methods var handles = []; var eventTypes = {add: 1, update: 1, 'delete': 1}; // register to listen for updates for (var type in eventTypes) { handles.push( this.on(type, (function (type) { return function (event) { notify(type, event); }; })(type)) ); } function makeFetch() { return function () { var self = this; var fetchResults = this.inherited(arguments); when(fetchResults, function (results) { results = self._results = results.slice(); if (self._partialResults) { // clean this up, as we don't need this anymore self._partialResults = null; } self._ranges = []; registerRange(self._ranges, 0, results.length); }); return fetchResults; }; } function makeFetchRange() { return function (kwArgs) { var self = this, start = kwArgs.start, end = kwArgs.end, fetchResults = this.inherited(arguments); // only use this if we don't have all the data if (!this._results) { when(fetchResults, function (results) { return when(results.totalLength, function (totalLength) { var partialResults = self._partialResults || (self._partialResults = []); end = Math.min(end, start + results.length); partialResults.length = totalLength; // copy the new ranged data into the parent partial data set var spliceArgs = [ start, end - start ].concat(results); partialResults.splice.apply(partialResults, spliceArgs); registerRange(self._ranges, start, end); return results; }); }); } return fetchResults; }; } // delegate rather than call _createSubCollection because we are not ultimately creating // a new collection, just decorating an existing collection with item index tracking. // If we use _createSubCollection, it will return a new collection that may exclude // important, defining properties from the tracked collection. var observed = declare.safeMixin(lang.delegate(this), { _ranges: [], fetch: makeFetch(), fetchRange: makeFetchRange(), releaseRange: function (start, end) { if (this._partialResults) { unregisterRange(this._ranges, start, end); for (var i = start; i < end; ++i) { delete this._partialResults[i]; } } }, on: function (type, listener) { var self = this, inheritedOn = this.getInherited(arguments); return on.parse(observed, type, listener, function (target, type) { return type in eventTypes ? aspect.after(observed, 'on_tracked' + type, listener, true) : inheritedOn.call(self, type, listener); }); }, tracking: { remove: function () { while (handles.length > 0) { handles.pop().remove(); } this.remove = function () {}; } }, // make sure track isn't called twice track: null }); if (this.fetchSync) { // only add these if we extending a sync-capable store declare.safeMixin(observed, { fetchSync: makeFetch(), fetchRangeSync: makeFetchRange() }); // we take the presence of fetchSync to indicate that the results can be // retrieved cheaply, and then we can just automatically fetch and start // tracking results observed.fetchSync(); } // Create a function that applies all queriers in the query log // in order to determine whether a new or updated item belongs // in the results and at what position. var queryExecutor; arrayUtil.forEach(this.queryLog, function (entry) { var existingQuerier = queryExecutor, querier = entry.querier; if (querier) { queryExecutor = existingQuerier ? function (data) { return querier(existingQuerier(data)); } : querier; } }); var defaultEventProps = { 'add': { index: undefined }, 'update': { previousIndex: undefined, index: undefined }, 'delete': { previousIndex: undefined } }, findObject = function (data, id, start, end) { start = start !== undefined ? start : 0; end = end !== undefined ? end : data.length; for (var i = start; i < end; ++i) { if (store.getIdentity(data[i]) === id) { return i; } } return -1; }; function notify(type, event) { revision++; var target = event.target; event = lang.delegate(event, defaultEventProps[type]); when(observed._results || observed._partialResults, function (resultsArray) { /* jshint maxcomplexity: 32 */ function emitEvent() { // TODO: Eventually we will want to aggregate all the listener events // in an event turn, but we will wait until we have a reliable, performant queueing // mechanism for this (besides setTimeout) var method = observed['on_tracked' + type]; method && method.call(observed, event); } if (!resultsArray) { // without data, we have no way to determine the indices effected by the change, // so just pass along the event and return. emitEvent(); return; } var i, j, l, ranges = observed._ranges, range; /*if(++queryRevision != revision){ throw new Error('Query is out of date, you must observe() the' + ' query prior to any data modifications'); }*/ var targetId = 'id' in event ? event.id : store.getIdentity(target); var removedFrom = -1, removalRangeIndex = -1, insertedInto = -1, insertionRangeIndex = -1; if (type === 'delete' || type === 'update') { // remove the old one for (i = 0; removedFrom === -1 && i < ranges.length; ++i) { range = ranges[i]; for (j = range.start, l = j + range.count; j < l; ++j) { var object = resultsArray[j]; // often ids can be converted strings (if they are used as keys in objects), // so we do a coercive equality check /* jshint eqeqeq: false */ if (store.getIdentity(object) == targetId) { removedFrom = event.previousIndex = j; removalRangeIndex = i; resultsArray.splice(removedFrom, 1); range.count--; for (j = i + 1; j < ranges.length; ++j) { ranges[j].start--; } break; } } } } if (type === 'add' || type === 'update') { if (queryExecutor) { // with a queryExecutor, we can determine the correct sorted index for the change if (queryExecutor([target]).length) { var begin = 0, end = ranges.length - 1, sampleArray, candidateIndex = -1, sortedIndex, adjustedIndex; while (begin <= end && insertedInto === -1) { // doing a binary search for the containing range i = begin + Math.round((end - begin) / 2); range = ranges[i]; sampleArray = resultsArray.slice(range.start, range.start + range.count); if ('beforeId' in event) { candidateIndex = event.beforeId === null ? sampleArray.length : findObject(sampleArray, event.beforeId); } if (candidateIndex === -1) { // If the original index came from this range, put back in the original slot // so it doesn't move unless it needs to (relying on a stable sort below) if (removedFrom >= Math.max(0, range.start - 1) && removedFrom <= (range.start + range.count)) { candidateIndex = removedFrom; } else { candidateIndex = store.defaultNewToStart ? 0 : sampleArray.length; } } sampleArray.splice(candidateIndex, 0, target); sortedIndex = arrayUtil.indexOf(queryExecutor(sampleArray), target); adjustedIndex = range.start + sortedIndex; if (sortedIndex === 0 && range.start !== 0) { end = i - 1; } else if (sortedIndex >= (sampleArray.length - 1) && adjustedIndex < resultsArray.length) { begin = i + 1; } else { insertedInto = adjustedIndex; insertionRangeIndex = i; } } if (insertedInto === -1 && begin > 0 && begin < ranges.length) { var betweenRanges = true; } } } else { // we don't have a queryExecutor, so we can't provide any information // about where it was inserted or moved to. If it is an update, we leave // its position alone. otherwise, we at least indicate a new object var range, possibleRangeIndex = -1; if ('beforeId' in event) { if (event.beforeId === null) { insertedInto = resultsArray.length; possibleRangeIndex = ranges.length - 1; } else { for (i = 0, l = ranges.length; insertionRangeIndex === -1 && i < l; ++i) { range = ranges[i]; insertedInto = findObject( resultsArray, event.beforeId, range.start, range.start + range.count ); if (insertedInto !== -1) { insertionRangeIndex = i; } } } } else { if (type === 'update') { insertedInto = removedFrom; insertionRangeIndex = removalRangeIndex; } else { if (store.defaultNewToStart) { insertedInto = 0; possibleRangeIndex = 0; } else { // default to the bottom insertedInto = resultsArray.length; possibleRangeIndex = ranges.length - 1; } } } if (possibleRangeIndex !== -1 && insertionRangeIndex === -1) { range = ranges[possibleRangeIndex]; if (range && range.start <= insertedInto && insertedInto <= (range.start + range.count)) { insertionRangeIndex = possibleRangeIndex; } } } // an item only truly has a known index if it is in a known range if (insertedInto > -1 && insertionRangeIndex > -1) { event.index = insertedInto; resultsArray.splice(insertedInto, 0, target); // update the count and start of the appropriate ranges ranges[insertionRangeIndex].count++; for (i = insertionRangeIndex + 1; i < ranges.length; ++i) { ranges[i].start++; } } else if (betweenRanges) { // the begin index will be after the inserted item, and is // where we can begin incrementing start values event.beforeIndex = ranges[begin].start; for (i = begin; i < ranges.length; ++i) { ranges[i].start++; } } } // update the total event.totalLength = resultsArray.length; emitEvent(); }); } return observed; } }; var Trackable = declare(null, trackablePrototype); Trackable.create = function (target, properties) { // create a delegate of an existing store with trackability functionality mixed in target = declare.safeMixin(lang.delegate(target), trackablePrototype); declare.safeMixin(target, properties); return target; }; return Trackable; });