diff --git a/bundles.json b/bundles.json index 31aedf69bb..2d900e8529 100644 --- a/bundles.json +++ b/bundles.json @@ -12,8 +12,8 @@ "platform/features/plot", "platform/features/scrolling", "platform/forms", - "platform/persistence/cache", - "platform/persistence/couch", + "platform/persistence/queue", + "platform/persistence/elastic", "example/generator" ] diff --git a/platform/commonUI/dialog/README.md b/platform/commonUI/dialog/README.md new file mode 100644 index 0000000000..a56fe0bb4a --- /dev/null +++ b/platform/commonUI/dialog/README.md @@ -0,0 +1,27 @@ +This bundle provides `dialogService`, which can be used to prompt +for user input. + +## `getUserChoice` + +The `getUserChoice` method is useful for displaying a message and a set of +buttons. This method returns a promise which will resolve to the user's +chosen option (or, more specifically, its `key`), and will be rejected if +the user closes the dialog with the X in the top-right; + +The `dialogModel` given as an argument to this method should have the +following properties. + +* `title`: The title to display at the top of the dialog. +* `hint`: Short message to display below the title. +* `template`: Identifying key (as will be passed to `mct-include`) for + the template which will be used to populate the inner area of the dialog. +* `model`: Model to pass in the `ng-model` attribute of + `mct-include`. +* `parameters`: Parameters to pass in the `parameters` attribute of + `mct-include`. +* `options`: An array of options describing each button at the bottom. + Each option may have the following properties: + * `name`: Human-readable name to display in the button. + * `key`: Machine-readable key, to pass as the result of the resolved + promise when clicked. + * `description`: Description to show in tool tip on hover. \ No newline at end of file diff --git a/platform/commonUI/dialog/bundle.json b/platform/commonUI/dialog/bundle.json index ae1c89cc05..9a2d541419 100644 --- a/platform/commonUI/dialog/bundle.json +++ b/platform/commonUI/dialog/bundle.json @@ -17,6 +17,10 @@ "key": "overlay-dialog", "templateUrl": "templates/overlay-dialog.html" }, + { + "key": "overlay-options", + "templateUrl": "templates/overlay-options.html" + }, { "key": "form-dialog", "templateUrl": "templates/dialog.html" diff --git a/platform/commonUI/dialog/res/templates/overlay-options.html b/platform/commonUI/dialog/res/templates/overlay-options.html new file mode 100644 index 0000000000..6c0b51e991 --- /dev/null +++ b/platform/commonUI/dialog/res/templates/overlay-options.html @@ -0,0 +1,24 @@ + +
+
{{ngModel.dialog.title}}
+
{{ngModel.dialog.hint}}
+
+
+
+ + +
+
+
+ + {{option.name}} + +
+
\ No newline at end of file diff --git a/platform/commonUI/dialog/src/DialogService.js b/platform/commonUI/dialog/src/DialogService.js index 344a407b94..147666765d 100644 --- a/platform/commonUI/dialog/src/DialogService.js +++ b/platform/commonUI/dialog/src/DialogService.js @@ -26,7 +26,7 @@ define( dialogVisible = false; } - function getUserInput(formModel, value) { + function getDialogResponse(key, model, resultGetter) { // We will return this result as a promise, because user // input is asynchronous. var deferred = $q.defer(), @@ -35,9 +35,9 @@ define( // Confirm function; this will be passed in to the // overlay-dialog template and associated with a // OK button click - function confirm() { + function confirm(value) { // Pass along the result - deferred.resolve(overlayModel.value); + deferred.resolve(resultGetter ? resultGetter() : value); // Stop showing the dialog dismiss(); @@ -51,6 +51,10 @@ define( dismiss(); } + // Add confirm/cancel callbacks + model.confirm = confirm; + model.cancel = cancel; + if (dialogVisible) { // Only one dialog should be shown at a time. // The application design should be such that @@ -58,26 +62,15 @@ define( $log.warn([ "Dialog already showing; ", "unable to show ", - formModel.name + model.name ].join("")); deferred.reject(); } else { - // To be passed to the overlay-dialog template, - // via ng-model - overlayModel = { - title: formModel.name, - message: formModel.message, - structure: formModel, - value: value, - confirm: confirm, - cancel: cancel - }; - // Add the overlay using the OverlayService, which // will handle actual insertion into the DOM overlay = overlayService.createOverlay( - "overlay-dialog", - overlayModel + key, + model ); // Track that a dialog is already visible, to @@ -88,6 +81,35 @@ define( return deferred.promise; } + function getUserInput(formModel, value) { + var overlayModel = { + title: formModel.name, + message: formModel.message, + structure: formModel, + value: value + }; + + // Provide result from the model + function resultGetter() { + return overlayModel.value; + } + + // Show the overlay-dialog + return getDialogResponse( + "overlay-dialog", + overlayModel, + resultGetter + ); + } + + function getUserChoice(dialogModel) { + // Show the overlay-options dialog + return getDialogResponse( + "overlay-options", + { dialog: dialogModel } + ); + } + return { /** * Request user input via a window-modal dialog. @@ -100,7 +122,14 @@ define( * user input cannot be obtained (for instance, * because the user cancelled the dialog) */ - getUserInput: getUserInput + getUserInput: getUserInput, + /** + * Request that the user chooses from a set of options, + * which will be shown as buttons. + * + * @param dialogModel a description of the dialog to show + */ + getUserChoice: getUserChoice }; } diff --git a/platform/commonUI/dialog/test/DialogServiceSpec.js b/platform/commonUI/dialog/test/DialogServiceSpec.js index 9f3635cd9e..7df54b6e8c 100644 --- a/platform/commonUI/dialog/test/DialogServiceSpec.js +++ b/platform/commonUI/dialog/test/DialogServiceSpec.js @@ -86,6 +86,19 @@ define( expect(mockDeferred.reject).not.toHaveBeenCalled(); }); + it("provides an options dialogs", function () { + var dialogModel = {}; + dialogService.getUserChoice(dialogModel); + expect(mockOverlayService.createOverlay).toHaveBeenCalledWith( + 'overlay-options', + { + dialog: dialogModel, + confirm: jasmine.any(Function), + cancel: jasmine.any(Function) + } + ); + }); + }); } ); \ No newline at end of file diff --git a/platform/commonUI/edit/bundle.json b/platform/commonUI/edit/bundle.json index eccaeaf787..aebca5ff92 100644 --- a/platform/commonUI/edit/bundle.json +++ b/platform/commonUI/edit/bundle.json @@ -10,7 +10,7 @@ { "key": "EditController", "implementation": "controllers/EditController.js", - "depends": [ "$scope", "navigationService" ] + "depends": [ "$scope", "$q", "navigationService" ] }, { "key": "EditActionController", diff --git a/platform/commonUI/edit/src/actions/SaveAction.js b/platform/commonUI/edit/src/actions/SaveAction.js index 3b79f74614..22da4b0751 100644 --- a/platform/commonUI/edit/src/actions/SaveAction.js +++ b/platform/commonUI/edit/src/actions/SaveAction.js @@ -13,24 +13,18 @@ define( function SaveAction($location, context) { var domainObject = context.domainObject; - // Look up the object's "editor.completion" capability; + // Invoke any save behavior introduced by the editor capability; // this is introduced by EditableDomainObject which is // used to insulate underlying objects from changes made // during editing. - function getEditorCapability() { - return domainObject.getCapability("editor"); - } - - // Invoke any save behavior introduced by the editor.completion - // capability. - function doSave(editor) { - return editor.save(); + function doSave() { + return domainObject.getCapability("editor").save(); } // Discard the current root view (which will be the editing // UI, which will have been pushed atop the Browise UI.) function returnToBrowse() { - $location.path("/browse"); + return $location.path("/browse"); } return { @@ -41,7 +35,7 @@ define( * cancellation has completed */ perform: function () { - return doSave(getEditorCapability()).then(returnToBrowse); + return doSave().then(returnToBrowse); } }; } diff --git a/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js b/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js index 8d39b61992..04eb83d7c6 100644 --- a/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js +++ b/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js @@ -29,6 +29,13 @@ define( cache.markDirty(editableObject); }; + // Delegate refresh to the original object; this avoids refreshing + // the editable instance of the object, and ensures that refresh + // correctly targets the "real" version of the object. + persistence.refresh = function () { + return domainObject.getCapability('persistence').refresh(); + }; + return persistence; } diff --git a/platform/commonUI/edit/src/capabilities/EditorCapability.js b/platform/commonUI/edit/src/capabilities/EditorCapability.js index 5ac88d0b68..666790601b 100644 --- a/platform/commonUI/edit/src/capabilities/EditorCapability.js +++ b/platform/commonUI/edit/src/capabilities/EditorCapability.js @@ -33,7 +33,7 @@ define( // removed from the layer which gets dependency // injection. function resolvePromise(value) { - return value && value.then ? value : { + return (value && value.then) ? value : { then: function (callback) { return resolvePromise(callback(value)); } @@ -50,19 +50,7 @@ define( // Persist the underlying domain object function doPersist() { - return persistenceCapability.persist(); - } - - // Save any other objects that have been modified in the cache. - // IMPORTANT: This must not be called until after this object - // has been marked as clean. - function saveOthers() { - return cache.saveAll(); - } - - // Indicate that this object has been saved. - function markClean() { - return cache.markClean(editableObject); + return domainObject.getCapability('persistence').persist(); } return { @@ -70,14 +58,15 @@ define( * Save any changes that have been made to this domain object * (as well as to others that might have been retrieved and * modified during the editing session) + * @param {boolean} nonrecursive if true, save only this + * object (and not other objects with associated changes) * @returns {Promise} a promise that will be fulfilled after * persistence has completed. */ - save: function () { - return resolvePromise(doMutate()) - .then(doPersist) - .then(markClean) - .then(saveOthers); + save: function (nonrecursive) { + return nonrecursive ? + resolvePromise(doMutate()).then(doPersist) : + resolvePromise(cache.saveAll()); }, /** * Cancel editing; Discard any changes that have been made to diff --git a/platform/commonUI/edit/src/controllers/EditController.js b/platform/commonUI/edit/src/controllers/EditController.js index cf07797429..2654e17f47 100644 --- a/platform/commonUI/edit/src/controllers/EditController.js +++ b/platform/commonUI/edit/src/controllers/EditController.js @@ -14,12 +14,12 @@ define( * navigated domain object into the scope. * @constructor */ - function EditController($scope, navigationService) { + function EditController($scope, $q, navigationService) { function setNavigation(domainObject) { // Wrap the domain object such that all mutation is // confined to edit mode (until Save) $scope.navigatedObject = - domainObject && new EditableDomainObject(domainObject); + domainObject && new EditableDomainObject(domainObject, $q); } setNavigation(navigationService.getNavigation()); diff --git a/platform/commonUI/edit/src/objects/EditableDomainObject.js b/platform/commonUI/edit/src/objects/EditableDomainObject.js index 4e3363c8e9..a7a4e7be3d 100644 --- a/platform/commonUI/edit/src/objects/EditableDomainObject.js +++ b/platform/commonUI/edit/src/objects/EditableDomainObject.js @@ -48,7 +48,7 @@ define( * and provides a "working copy" of the object's * model to allow changes to be easily cancelled. */ - function EditableDomainObject(domainObject) { + function EditableDomainObject(domainObject, $q) { // The cache will hold all domain objects reached from // the initial EditableDomainObject; this ensures that // different versions of the same editable domain object @@ -81,7 +81,7 @@ define( return editableObject; } - cache = new EditableDomainObjectCache(EditableDomainObjectImpl); + cache = new EditableDomainObjectCache(EditableDomainObjectImpl, $q); return cache.getEditableObject(domainObject); } diff --git a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js index 3509b9675a..a342fcdad8 100644 --- a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js +++ b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js @@ -29,10 +29,11 @@ define( * constructor function which takes a regular domain object as * an argument, and returns an editable domain object as its * result. + * @param $q Angular's $q, for promise handling * @constructor * @memberof module:editor/object/editable-domain-object-cache */ - function EditableDomainObjectCache(EditableDomainObject) { + function EditableDomainObjectCache(EditableDomainObject, $q) { var cache = new EditableModelCache(), dirty = {}, root; @@ -88,23 +89,20 @@ define( * Initiate a save on all objects that have been cached. */ saveAll: function () { - var object; + // Get a list of all dirty objects + var objects = Object.keys(dirty).map(function (k) { + return dirty[k]; + }); + + // Clear dirty set, since we're about to save. + dirty = {}; // Most save logic is handled by the "editor.completion" - // capability, but this in turn will typically invoke - // Save All. An infinite loop is avoided by marking - // objects as clean as we go. - - while (Object.keys(dirty).length > 0) { - // Pick the first dirty object - object = dirty[Object.keys(dirty)[0]]; - - // Mark non-dirty to avoid successive invocations - this.markClean(object); - - // Invoke its save behavior - object.getCapability('editor').save(); - } + // capability, so that is delegated here. + return $q.all(objects.map(function (object) { + // Save; pass a nonrecursive flag to avoid looping + return object.getCapability('editor').save(true); + })); } }; } diff --git a/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js b/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js index 327c49bdc1..af89bee75f 100644 --- a/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js +++ b/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js @@ -15,7 +15,7 @@ define( beforeEach(function () { mockPersistence = jasmine.createSpyObj( "persistence", - [ "persist" ] + [ "persist", "refresh" ] ); mockEditableObject = jasmine.createSpyObj( "editableObject", @@ -30,6 +30,8 @@ define( [ "markDirty" ] ); + mockDomainObject.getCapability.andReturn(mockPersistence); + capability = new EditablePersistenceCapability( mockPersistence, mockEditableObject, @@ -49,6 +51,18 @@ define( expect(mockPersistence.persist).not.toHaveBeenCalled(); }); + it("refreshes using the original domain object's persistence", function () { + // Refreshing needs to delegate via the unwrapped domain object. + // Otherwise, only the editable version of the object will be updated; + // we instead want the real version of the object to receive these + // changes. + expect(mockDomainObject.getCapability).not.toHaveBeenCalled(); + expect(mockPersistence.refresh).not.toHaveBeenCalled(); + capability.refresh(); + expect(mockDomainObject.getCapability).toHaveBeenCalledWith('persistence'); + expect(mockPersistence.refresh).toHaveBeenCalled(); + }); + }); } ); \ No newline at end of file diff --git a/platform/commonUI/edit/test/capabilities/EditorCapabilitySpec.js b/platform/commonUI/edit/test/capabilities/EditorCapabilitySpec.js index 041f5c6734..72d1cb9750 100644 --- a/platform/commonUI/edit/test/capabilities/EditorCapabilitySpec.js +++ b/platform/commonUI/edit/test/capabilities/EditorCapabilitySpec.js @@ -5,7 +5,7 @@ define( function (EditorCapability) { "use strict"; - describe("An editable context capability", function () { + describe("The editor capability", function () { var mockPersistence, mockEditableObject, mockDomainObject, @@ -32,6 +32,8 @@ define( ); mockCallback = jasmine.createSpy("callback"); + mockDomainObject.getCapability.andReturn(mockPersistence); + model = { someKey: "some value", x: 42 }; capability = new EditorCapability( @@ -42,8 +44,8 @@ define( ); }); - it("mutates the real domain object on save", function () { - capability.save().then(mockCallback); + it("mutates the real domain object on nonrecursive save", function () { + capability.save(true).then(mockCallback); // Wait for promise to resolve waitsFor(function () { @@ -60,19 +62,6 @@ define( }); }); - it("marks the saved object as clean in the editing cache", function () { - capability.save().then(mockCallback); - - // Wait for promise to resolve - waitsFor(function () { - return mockCallback.calls.length > 0; - }, 250); - - runs(function () { - expect(mockCache.markClean).toHaveBeenCalledWith(mockEditableObject); - }); - }); - it("tells the cache to save others", function () { capability.save().then(mockCallback); diff --git a/platform/commonUI/edit/test/controllers/EditControllerSpec.js b/platform/commonUI/edit/test/controllers/EditControllerSpec.js index f945c499b8..0d37bd3820 100644 --- a/platform/commonUI/edit/test/controllers/EditControllerSpec.js +++ b/platform/commonUI/edit/test/controllers/EditControllerSpec.js @@ -7,6 +7,7 @@ define( describe("The Edit mode controller", function () { var mockScope, + mockQ, mockNavigationService, mockObject, mockCapability, @@ -17,6 +18,7 @@ define( "$scope", [ "$on" ] ); + mockQ = jasmine.createSpyObj('$q', ['when', 'all']); mockNavigationService = jasmine.createSpyObj( "navigationService", [ "getNavigation", "addListener", "removeListener" ] @@ -37,6 +39,7 @@ define( controller = new EditController( mockScope, + mockQ, mockNavigationService ); }); diff --git a/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js b/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js index 5e71368553..001b5a4ad1 100644 --- a/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js +++ b/platform/commonUI/edit/test/objects/EditableDomainObjectCacheSpec.js @@ -1,4 +1,4 @@ -/*global define,describe,it,expect,beforeEach*/ +/*global define,describe,it,expect,beforeEach,jasmine*/ define( ["../../src/objects/EditableDomainObjectCache"], @@ -10,6 +10,7 @@ define( var captured, completionCapability, object, + mockQ, cache; @@ -33,6 +34,7 @@ define( } beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['when', 'all']); captured = {}; completionCapability = { save: function () { @@ -40,7 +42,7 @@ define( } }; - cache = new EditableDomainObjectCache(WrapObject); + cache = new EditableDomainObjectCache(WrapObject, mockQ); }); it("wraps objects using provided constructor", function () { diff --git a/platform/core/bundle.json b/platform/core/bundle.json index 46f4b81f72..5a2727eb75 100644 --- a/platform/core/bundle.json +++ b/platform/core/bundle.json @@ -65,6 +65,11 @@ "implementation": "models/PersistedModelProvider.js", "depends": [ "persistenceService", "$q", "PERSISTENCE_SPACE" ] }, + { + "provides": "modelService", + "type": "decorator", + "implementation": "models/CachingModelDecorator.js" + }, { "provides": "typeService", "type": "provider", @@ -162,7 +167,8 @@ }, { "key": "mutation", - "implementation": "capabilities/MutationCapability.js" + "implementation": "capabilities/MutationCapability.js", + "depends": [ "now" ] }, { "key": "delegation", diff --git a/platform/core/src/capabilities/MutationCapability.js b/platform/core/src/capabilities/MutationCapability.js index 9a36d60180..2e8912c531 100644 --- a/platform/core/src/capabilities/MutationCapability.js +++ b/platform/core/src/capabilities/MutationCapability.js @@ -50,9 +50,9 @@ define( * which will expose this capability * @constructor */ - function MutationCapability(domainObject) { + function MutationCapability(now, domainObject) { - function mutate(mutator) { + function mutate(mutator, timestamp) { // Get the object's model and clone it, so the // mutator function has a temporary copy to work with. var model = domainObject.getModel(), @@ -73,7 +73,8 @@ define( if (model !== result) { copyValues(model, result); } - model.modified = Date.now(); + model.modified = (typeof timestamp === 'number') ? + timestamp : now(); } // Report the result of the mutation @@ -109,8 +110,11 @@ define( * handled as one of the above. * * - * @params {function} mutator the function which will make + * @param {function} mutator the function which will make * changes to the domain object's model. + * @param {number} [timestamp] timestamp to record for + * this mutation (otherwise, system time will be + * used) * @returns {Promise.} a promise for the result * of the mutation; true if changes were made. */ diff --git a/platform/core/src/capabilities/PersistenceCapability.js b/platform/core/src/capabilities/PersistenceCapability.js index d26426d82a..bc9479802c 100644 --- a/platform/core/src/capabilities/PersistenceCapability.js +++ b/platform/core/src/capabilities/PersistenceCapability.js @@ -22,6 +22,36 @@ define( * @constructor */ function PersistenceCapability(persistenceService, SPACE, domainObject) { + // Cache modified timestamp + var modified = domainObject.getModel().modified; + + // Update a domain object's model upon refresh + function updateModel(model) { + var modified = model.modified; + return domainObject.useCapability("mutation", function () { + return model; + }, modified); + } + + // For refresh; update a domain object model, only if there + // are no unsaved changes. + function updatePersistenceTimestamp() { + var modified = domainObject.getModel().modified; + domainObject.useCapability("mutation", function (model) { + model.persisted = modified; + }, modified); + } + + // Utility function for creating promise-like objects which + // resolve synchronously when possible + function fastPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return fastPromise(callback(value)); + } + }; + } + return { /** * Persist any changes which have been made to this @@ -31,12 +61,29 @@ define( * if not. */ persist: function () { + updatePersistenceTimestamp(); return persistenceService.updateObject( SPACE, domainObject.getId(), domainObject.getModel() ); }, + /** + * Update this domain object to match the latest from + * persistence. + * @returns {Promise} a promise which will be resolved + * when the update is complete + */ + refresh: function () { + var model = domainObject.getModel(); + // Only update if we don't have unsaved changes + return (model.modified === model.persisted) ? + persistenceService.readObject( + SPACE, + domainObject.getId() + ).then(updateModel) : + fastPromise(false); + }, /** * Get the space in which this domain object is persisted; * this is useful when, for example, decided which space a diff --git a/platform/core/src/models/CachingModelDecorator.js b/platform/core/src/models/CachingModelDecorator.js new file mode 100644 index 0000000000..a33e1f0647 --- /dev/null +++ b/platform/core/src/models/CachingModelDecorator.js @@ -0,0 +1,115 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * The caching model decorator maintains a cache of loaded domain + * object models, and ensures that duplicate models for the same + * object are not provided. + * @constructor + */ + function CachingModelDecorator(modelService) { + var cache = {}, + cached = {}; + + // Update the cached instance of a model to a new value. + // We update in-place to ensure there is only ever one instance + // of any given model exposed by the modelService as a whole. + function updateModel(id, model) { + var oldModel = cache[id]; + + // Same object instance is a possibility, so don't copy + if (oldModel === model) { + return model; + } + + // If we'd previously cached an undefined value, or are now + // seeing undefined, replace the item in the cache entirely. + if (oldModel === undefined || model === undefined) { + cache[id] = model; + return model; + } + + // Otherwise, empty out the old model... + Object.keys(oldModel).forEach(function (k) { + delete oldModel[k]; + }); + + // ...and replace it with the contents of the new model. + Object.keys(model).forEach(function (k) { + oldModel[k] = model[k]; + }); + + return oldModel; + } + + // Fast-resolving promise + function fastPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return fastPromise(callback(value)); + } + }; + } + + // Store this model in the cache + function cacheModel(id, model) { + cache[id] = cached[id] ? updateModel(id, model) : model; + cached[id] = true; + } + + // Check if an id is not in cache, for lookup filtering + function notCached(id) { + return !cached[id]; + } + + // Store the provided models in our cache + function cacheAll(models) { + Object.keys(models).forEach(function (id) { + cacheModel(id, models[id]); + }); + } + + // Expose the cache (for promise chaining) + function giveCache() { + return cache; + } + + return { + /** + * Get models for these specified string identifiers. + * These will be given as an object containing keys + * and values, where keys are object identifiers and + * values are models. + * This result may contain either a subset or a + * superset of the total objects. + * + * @param {Array} ids the string identifiers for + * models of interest. + * @returns {Promise} a promise for an object + * containing key-value pairs, where keys are + * ids and values are models + * @method + */ + getModels: function (ids) { + var neededIds = ids.filter(notCached); + + // Look up if we have unknown IDs + if (neededIds.length > 0) { + return modelService.getModels(neededIds) + .then(cacheAll) + .then(giveCache); + } + + // Otherwise, just expose the cache directly + return fastPromise(cache); + } + }; + } + + return CachingModelDecorator; + } +); \ No newline at end of file diff --git a/platform/core/src/models/ModelAggregator.js b/platform/core/src/models/ModelAggregator.js index 933674f83a..d02192830e 100644 --- a/platform/core/src/models/ModelAggregator.js +++ b/platform/core/src/models/ModelAggregator.js @@ -18,6 +18,14 @@ define( */ function ModelAggregator($q, providers) { + // Pick a domain object model to use, favoring the one + // with the most recent timestamp + function pick(a, b) { + var aModified = (a || {}).modified || Number.NEGATIVE_INFINITY, + bModified = (b || {}).modified || Number.NEGATIVE_INFINITY; + return (aModified > bModified) ? a : (b || a); + } + // Merge results from multiple providers into one // large result object. function mergeModels(provided, ids) { @@ -25,7 +33,7 @@ define( ids.forEach(function (id) { provided.forEach(function (models) { if (models[id]) { - result[id] = models[id]; + result[id] = pick(result[id], models[id]); } }); }); diff --git a/platform/core/test/capabilities/MutationCapabilitySpec.js b/platform/core/test/capabilities/MutationCapabilitySpec.js index 83536347f3..55a5ea7957 100644 --- a/platform/core/test/capabilities/MutationCapabilitySpec.js +++ b/platform/core/test/capabilities/MutationCapabilitySpec.js @@ -10,12 +10,15 @@ define( describe("The mutation capability", function () { var testModel, + mockNow, domainObject = { getModel: function () { return testModel; } }, mutation; beforeEach(function () { testModel = { number: 6 }; - mutation = new MutationCapability(domainObject); + mockNow = jasmine.createSpy('now'); + mockNow.andReturn(12321); + mutation = new MutationCapability(mockNow, domainObject); }); it("allows mutation of a model", function () { @@ -41,6 +44,24 @@ define( // Number should not have been changed expect(testModel.number).toEqual(6); }); + + it("attaches a timestamp on mutation", function () { + // Verify precondition + expect(testModel.modified).toBeUndefined(); + mutation.invoke(function (m) { + m.number = m.number * 7; + }); + // Should have gotten a timestamp from 'now' + expect(testModel.modified).toEqual(12321); + }); + + it("allows a timestamp to be provided", function () { + mutation.invoke(function (m) { + m.number = m.number * 7; + }, 42); + // Should have gotten a timestamp from 'now' + expect(testModel.modified).toEqual(42); + }); }); } ); \ No newline at end of file diff --git a/platform/core/test/capabilities/PersistenceCapabilitySpec.js b/platform/core/test/capabilities/PersistenceCapabilitySpec.js index a758745b9d..d5be5bf659 100644 --- a/platform/core/test/capabilities/PersistenceCapabilitySpec.js +++ b/platform/core/test/capabilities/PersistenceCapabilitySpec.js @@ -16,15 +16,30 @@ define( SPACE = "some space", persistence; + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + beforeEach(function () { mockPersistenceService = jasmine.createSpyObj( "persistenceService", - [ "updateObject" ] + [ "updateObject", "readObject" ] ); mockDomainObject = { getId: function () { return id; }, - getModel: function () { return model; } + getModel: function () { return model; }, + useCapability: jasmine.createSpy() }; + // Simulate mutation capability + mockDomainObject.useCapability.andCallFake(function (capability, mutator) { + if (capability === 'mutation') { + model = mutator(model) || model; + } + }); persistence = new PersistenceCapability( mockPersistenceService, SPACE, @@ -49,6 +64,31 @@ define( expect(persistence.getSpace()).toEqual(SPACE); }); + it("updates persisted timestamp on persistence", function () { + model.modified = 12321; + persistence.persist(); + expect(model.persisted).toEqual(12321); + }); + + it("refreshes the domain object model from persistence", function () { + var refreshModel = { someOtherKey: "some other value" }; + mockPersistenceService.readObject.andReturn(asPromise(refreshModel)); + persistence.refresh(); + expect(model).toEqual(refreshModel); + }); + + it("does not overwrite unpersisted changes on refresh", function () { + var refreshModel = { someOtherKey: "some other value" }, + mockCallback = jasmine.createSpy(); + model.modified = 2; + model.persisted = 1; + mockPersistenceService.readObject.andReturn(asPromise(refreshModel)); + persistence.refresh().then(mockCallback); + expect(model).not.toEqual(refreshModel); + // Should have also indicated that no changes were actually made + expect(mockCallback).toHaveBeenCalledWith(false); + }); + }); } ); \ No newline at end of file diff --git a/platform/core/test/models/CachingModelDecoratorSpec.js b/platform/core/test/models/CachingModelDecoratorSpec.js new file mode 100644 index 0000000000..6095c0aaed --- /dev/null +++ b/platform/core/test/models/CachingModelDecoratorSpec.js @@ -0,0 +1,132 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/models/CachingModelDecorator"], + function (CachingModelDecorator) { + "use strict"; + + describe("The caching model decorator", function () { + var mockModelService, + mockCallback, + testModels, + decorator; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + function fakePromise() { + var chains = [], + callbacks = []; + + return { + then: function (callback) { + var next = fakePromise(); + callbacks.push(callback); + chains.push(next); + return next; + }, + resolve: function (value) { + callbacks.forEach(function (cb, i) { + chains[i].resolve(cb(value)); + }); + } + }; + } + + beforeEach(function () { + mockCallback = jasmine.createSpy(); + mockModelService = jasmine.createSpyObj('modelService', ['getModels']); + testModels = { + a: { someKey: "some value" }, + b: { someOtherKey: "some other value" } + }; + mockModelService.getModels.andReturn(asPromise(testModels)); + decorator = new CachingModelDecorator(mockModelService); + }); + + it("loads models from its wrapped model service", function () { + decorator.getModels(['a', 'b']).then(mockCallback); + expect(mockCallback).toHaveBeenCalledWith(testModels); + }); + + it("does not try to reload cached models", function () { + mockModelService.getModels.andReturn(asPromise({ a: testModels.a })); + decorator.getModels(['a']); + mockModelService.getModels.andReturn(asPromise(testModels)); + decorator.getModels(['a', 'b']); + expect(mockModelService.getModels).not.toHaveBeenCalledWith(['a', 'b']); + expect(mockModelService.getModels.mostRecentCall.args[0]).toEqual(['b']); + }); + + it("does not call its wrapped model service if not needed", function () { + decorator.getModels(['a', 'b']); + expect(mockModelService.getModels.calls.length).toEqual(1); + decorator.getModels(['a', 'b']).then(mockCallback); + expect(mockModelService.getModels.calls.length).toEqual(1); + // Verify that we still got back our models, even though + // no new call to the wrapped service was made + expect(mockCallback).toHaveBeenCalledWith(testModels); + }); + + it("ensures a single object instance, even for multiple concurrent calls", function () { + var promiseA, promiseB, mockCallback = jasmine.createSpy(); + + promiseA = fakePromise(); + promiseB = fakePromise(); + + // Issue two calls before those promises resolve + mockModelService.getModels.andReturn(promiseA); + decorator.getModels(['a']); + mockModelService.getModels.andReturn(promiseB); + decorator.getModels(['a']).then(mockCallback); + + // Then resolve those promises. Note that we're whiteboxing here + // to figure out which promises to resolve (that is, we know that + // two thens are chained after each getModels) + promiseA.resolve(testModels); + promiseB.resolve({ + a: { someNewKey: "some other value" } + }); + + // Ensure that we have a pointer-identical instance + expect(mockCallback.mostRecentCall.args[0].a) + .toEqual({ someNewKey: "some other value" }); + expect(mockCallback.mostRecentCall.args[0].a) + .toBe(testModels.a); + }); + + it("is robust against updating with undefined values", function () { + var promiseA, promiseB, mockCallback = jasmine.createSpy(); + + promiseA = fakePromise(); + promiseB = fakePromise(); + + // Issue two calls before those promises resolve + mockModelService.getModels.andReturn(promiseA); + decorator.getModels(['a']); + mockModelService.getModels.andReturn(promiseB); + decorator.getModels(['a']).then(mockCallback); + + // Some model providers might erroneously add undefined values + // under requested keys, so handle that + promiseA.resolve({ + a: undefined + }); + promiseB.resolve({ + a: { someNewKey: "some other value" } + }); + + // Should still have gotten the model + expect(mockCallback.mostRecentCall.args[0].a) + .toEqual({ someNewKey: "some other value" }); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/core/test/models/ModelAggregatorSpec.js b/platform/core/test/models/ModelAggregatorSpec.js index d01aeb022d..ff8e8ebf43 100644 --- a/platform/core/test/models/ModelAggregatorSpec.js +++ b/platform/core/test/models/ModelAggregatorSpec.js @@ -12,8 +12,8 @@ define( var mockQ, mockProviders, modelList = [ - { "a": { someKey: "some value" } }, - { "b": { someOtherKey: "some other value" } } + { "a": { someKey: "some value" }, "b": undefined }, + { "b": { someOtherKey: "some other value" }, "a": undefined } ], aggregator; diff --git a/platform/core/test/suite.json b/platform/core/test/suite.json index 68990a191e..36f3e81980 100644 --- a/platform/core/test/suite.json +++ b/platform/core/test/suite.json @@ -17,6 +17,7 @@ "models/PersistedModelProvider", "models/RootModelProvider", "models/StaticModelProvider", + "models/CachingModelDecorator", "objects/DomainObject", "objects/DomainObjectProvider", diff --git a/platform/persistence/cache/src/CachingPersistenceDecorator.js b/platform/persistence/cache/src/CachingPersistenceDecorator.js index 06e784d928..f495c03ca9 100644 --- a/platform/persistence/cache/src/CachingPersistenceDecorator.js +++ b/platform/persistence/cache/src/CachingPersistenceDecorator.js @@ -148,8 +148,11 @@ define( * failure of this request */ updateObject: function (space, key, value) { - addToCache(space, key, value); - return persistenceService.updateObject(space, key, value); + return persistenceService.updateObject(space, key, value) + .then(function (result) { + addToCache(space, key, value); + return result; + }); }, /** * Delete an object in a specific space. This will diff --git a/platform/persistence/elastic/README.md b/platform/persistence/elastic/README.md new file mode 100644 index 0000000000..2874386784 --- /dev/null +++ b/platform/persistence/elastic/README.md @@ -0,0 +1,2 @@ +This bundle implements a connection to an external ElasticSearch persistence +store in Open MCT Web. diff --git a/platform/persistence/elastic/bundle.json b/platform/persistence/elastic/bundle.json new file mode 100644 index 0000000000..53f8571e1a --- /dev/null +++ b/platform/persistence/elastic/bundle.json @@ -0,0 +1,43 @@ +{ + "name": "Couch Persistence", + "description": "Adapter to read and write objects using a CouchDB instance.", + "extensions": { + "components": [ + { + "provides": "persistenceService", + "type": "provider", + "implementation": "ElasticPersistenceProvider.js", + "depends": [ "$http", "$q", "PERSISTENCE_SPACE", "ELASTIC_ROOT", "ELASTIC_PATH" ] + } + ], + "constants": [ + { + "key": "PERSISTENCE_SPACE", + "value": "mct" + }, + { + "key": "ELASTIC_ROOT", + "value": "/elastic" + }, + { + "key": "ELASTIC_PATH", + "value": "mct/domain_object" + }, + { + "key": "ELASTIC_INDICATOR_INTERVAL", + "value": 15000 + } + ], + "indicators": [ + { + "implementation": "ElasticIndicator.js", + "depends": [ + "$http", + "$interval", + "ELASTIC_ROOT", + "ELASTIC_INDICATOR_INTERVAL" + ] + } + ] + } +} \ No newline at end of file diff --git a/platform/persistence/elastic/src/ElasticIndicator.js b/platform/persistence/elastic/src/ElasticIndicator.js new file mode 100644 index 0000000000..a1653a8093 --- /dev/null +++ b/platform/persistence/elastic/src/ElasticIndicator.js @@ -0,0 +1,95 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + // Set of connection states; changing among these states will be + // reflected in the indicator's appearance. + // CONNECTED: Everything nominal, expect to be able to read/write. + // DISCONNECTED: HTTP failed; maybe misconfigured, disconnected. + // PENDING: Still trying to connect, and haven't failed yet. + var CONNECTED = { + text: "Connected", + glyphClass: "ok", + description: "Connected to the domain object database." + }, + DISCONNECTED = { + text: "Disconnected", + glyphClass: "err", + description: "Unable to connect to the domain object database." + }, + PENDING = { + text: "Checking connection..." + }; + + /** + * Indicator for the current CouchDB connection. Polls CouchDB + * at a regular interval (defined by bundle constants) to ensure + * that the database is available. + */ + function ElasticIndicator($http, $interval, PATH, INTERVAL) { + // Track the current connection state + var state = PENDING; + + // Callback if the HTTP request to Couch fails + function handleError(err) { + state = DISCONNECTED; + } + + // Callback if the HTTP request succeeds. + function handleResponse(response) { + state = CONNECTED; + } + + // Try to connect to CouchDB, and update the indicator. + function updateIndicator() { + $http.get(PATH).then(handleResponse, handleError); + } + + // Update the indicator initially, and start polling. + updateIndicator(); + $interval(updateIndicator, INTERVAL, false); + + return { + /** + * Get the glyph (single character used as an icon) + * to display in this indicator. This will return "D", + * which should appear as a database icon. + * @returns {string} the character of the database icon + */ + getGlyph: function () { + return "D"; + }, + /** + * Get the name of the CSS class to apply to the glyph. + * This is used to color the glyph to match its + * state (one of ok, caution or err) + * @returns {string} the CSS class to apply to this glyph + */ + getGlyphClass: function () { + return state.glyphClass; + }, + /** + * Get the text that should appear in the indicator. + * @returns {string} brief summary of connection status + */ + getText: function () { + return state.text; + }, + /** + * Get a longer-form description of the current connection + * space, suitable for display in a tooltip + * @returns {string} longer summary of connection status + */ + getDescription: function () { + return state.description; + } + }; + + } + + return ElasticIndicator; + } +); \ No newline at end of file diff --git a/platform/persistence/elastic/src/ElasticPersistenceProvider.js b/platform/persistence/elastic/src/ElasticPersistenceProvider.js new file mode 100644 index 0000000000..9b8651d7f2 --- /dev/null +++ b/platform/persistence/elastic/src/ElasticPersistenceProvider.js @@ -0,0 +1,179 @@ +/*global define*/ + +define( + [], + function () { + 'use strict'; + + // JSLint doesn't like underscore-prefixed properties, + // so hide them here. + var SRC = "_source", + REV = "_version", + ID = "_id", + CONFLICT = 409; + + /** + * The ElasticPersistenceProvider reads and writes JSON documents + * (more specifically, domain object models) to/from an ElasticSearch + * instance. + * @constructor + */ + function ElasticPersistenceProvider($http, $q, SPACE, ROOT, PATH) { + var spaces = [ SPACE ], + revs = {}; + + // Convert a subpath to a full path, suitable to pass + // to $http. + function url(subpath) { + return ROOT + '/' + PATH + '/' + subpath; + } + + // Issue a request using $http; get back the plain JS object + // from the expected JSON response + function request(subpath, method, value, params) { + return $http({ + method: method, + url: url(subpath), + params: params, + data: value + }).then(function (response) { + return response.data; + }, function (response) { + return (response || {}).data; + }); + } + + // Shorthand methods for GET/PUT methods + function get(subpath) { + return request(subpath, "GET"); + } + function put(subpath, value, params) { + return request(subpath, "PUT", value, params); + } + function del(subpath) { + return request(subpath, "DELETE"); + } + + // Get a domain object model out of CouchDB's response + function getModel(response) { + if (response && response[SRC]) { + revs[response[ID]] = response[REV]; + return response[SRC]; + } else { + return undefined; + } + } + + // Handle an update error + function handleError(response, key) { + var error = new Error("Persistence error."); + if ((response || {}).status === CONFLICT) { + error.key = "revision"; + // Load the updated model, then reject the promise + return get(key).then(function (response) { + error.model = response[SRC]; + return $q.reject(error); + }); + } + // Reject the promise + return $q.reject(error); + } + + // Check the response to a create/update/delete request; + // track the rev if it's valid, otherwise return false to + // indicate that the request failed. + function checkResponse(response, key) { + var error; + if (response && !response.error) { + revs[key] = response[REV]; + return response; + } else { + return handleError(response, key); + } + } + + return { + /** + * List all persistence spaces which this provider + * recognizes. + * + * @returns {Promise.} a promise for a list of + * spaces supported by this provider + */ + listSpaces: function () { + return $q.when(spaces); + }, + /** + * List all objects (by their identifiers) that are stored + * in the given persistence space, per this provider. + * @param {string} space the space to check + * @returns {Promise.} a promise for the list of + * identifiers + */ + listObjects: function (space) { + return $q.when([]); + }, + /** + * Create a new object in the specified persistence space. + * @param {string} space the space in which to store the object + * @param {string} key the identifier for the persisted object + * @param {object} value a JSONifiable object that should be + * stored and associated with the provided identifier + * @returns {Promise.} a promise for an indication + * of the success (true) or failure (false) of this + * operation + */ + createObject: function (space, key, value) { + return put(key, value).then(checkResponse); + }, + + /** + * Read an existing object back from persistence. + * @param {string} space the space in which to look for + * the object + * @param {string} key the identifier for the persisted object + * @returns {Promise.} a promise for the stored + * object; this will resolve to undefined if no such + * object is found. + */ + readObject: function (space, key) { + return get(key).then(getModel); + }, + /** + * Update an existing object in the specified persistence space. + * @param {string} space the space in which to store the object + * @param {string} key the identifier for the persisted object + * @param {object} value a JSONifiable object that should be + * stored and associated with the provided identifier + * @returns {Promise.} a promise for an indication + * of the success (true) or failure (false) of this + * operation + */ + updateObject: function (space, key, value) { + function checkUpdate(response) { + return checkResponse(response, key); + } + return put(key, value, { version: revs[key] }) + .then(checkUpdate); + }, + /** + * Delete an object in the specified persistence space. + * @param {string} space the space from which to delete this + * object + * @param {string} key the identifier of the persisted object + * @param {object} value a JSONifiable object that should be + * deleted + * @returns {Promise.} a promise for an indication + * of the success (true) or failure (false) of this + * operation + */ + deleteObject: function (space, key, value) { + return del(key).then(checkResponse); + } + }; + + } + + return ElasticPersistenceProvider; + } +); \ No newline at end of file diff --git a/platform/persistence/elastic/test/ElasticIndicatorSpec.js b/platform/persistence/elastic/test/ElasticIndicatorSpec.js new file mode 100644 index 0000000000..1285020f08 --- /dev/null +++ b/platform/persistence/elastic/test/ElasticIndicatorSpec.js @@ -0,0 +1,90 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/ElasticIndicator"], + function (ElasticIndicator) { + "use strict"; + + describe("The ElasticSearch status indicator", function () { + var mockHttp, + mockInterval, + testPath, + testInterval, + mockPromise, + indicator; + + beforeEach(function () { + mockHttp = jasmine.createSpyObj("$http", [ "get" ]); + mockInterval = jasmine.createSpy("$interval"); + mockPromise = jasmine.createSpyObj("promise", [ "then" ]); + testPath = "/test/path"; + testInterval = 12321; // Some number + + mockHttp.get.andReturn(mockPromise); + + indicator = new ElasticIndicator( + mockHttp, + mockInterval, + testPath, + testInterval + ); + }); + + it("polls for changes", function () { + expect(mockInterval).toHaveBeenCalledWith( + jasmine.any(Function), + testInterval, + false + ); + }); + + it("has a database icon", function () { + expect(indicator.getGlyph()).toEqual("D"); + }); + + it("consults the database at the configured path", function () { + expect(mockHttp.get).toHaveBeenCalledWith(testPath); + }); + + it("changes when the database connection is nominal", function () { + var initialText = indicator.getText(), + initialDescrption = indicator.getDescription(), + initialGlyphClass = indicator.getGlyphClass(); + + // Nominal just means getting back an objeect, without + // an error field. + mockPromise.then.mostRecentCall.args[0]({ data: {} }); + + // Verify that these values changed; + // don't test for specific text. + expect(indicator.getText()).not.toEqual(initialText); + expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass); + expect(indicator.getDescription()).not.toEqual(initialDescrption); + + // Do check for specific class + expect(indicator.getGlyphClass()).toEqual("ok"); + }); + + it("changes when the server cannot be reached", function () { + var initialText = indicator.getText(), + initialDescrption = indicator.getDescription(), + initialGlyphClass = indicator.getGlyphClass(); + + // Nominal just means getting back an objeect, without + // an error field. + mockPromise.then.mostRecentCall.args[1]({ data: {} }); + + // Verify that these values changed; + // don't test for specific text. + expect(indicator.getText()).not.toEqual(initialText); + expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass); + expect(indicator.getDescription()).not.toEqual(initialDescrption); + + // Do check for specific class + expect(indicator.getGlyphClass()).toEqual("err"); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/elastic/test/ElasticPersistenceProviderSpec.js b/platform/persistence/elastic/test/ElasticPersistenceProviderSpec.js new file mode 100644 index 0000000000..a474f74c8a --- /dev/null +++ b/platform/persistence/elastic/test/ElasticPersistenceProviderSpec.js @@ -0,0 +1,195 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/ElasticPersistenceProvider"], + function (ElasticPersistenceProvider) { + "use strict"; + + describe("The ElasticSearch persistence provider", function () { + var mockHttp, + mockQ, + testSpace = "testSpace", + testRoot = "/test", + testPath = "db", + capture, + provider; + + function mockPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return mockPromise(callback(value)); + } + }; + } + + beforeEach(function () { + mockHttp = jasmine.createSpy("$http"); + mockQ = jasmine.createSpyObj("$q", ["when", "reject"]); + + mockQ.when.andCallFake(mockPromise); + mockQ.reject.andCallFake(function (value) { + return { + then: function (ignored, callback) { + return mockPromise(callback(value)); + } + }; + }); + + // Capture promise results + capture = jasmine.createSpy("capture"); + + provider = new ElasticPersistenceProvider( + mockHttp, + mockQ, + testSpace, + testRoot, + testPath + ); + }); + + it("reports available spaces", function () { + provider.listSpaces().then(capture); + expect(capture).toHaveBeenCalledWith([testSpace]); + }); + + // General pattern of tests below is to simulate ElasticSearch's + // response, verify that request looks like what ElasticSearch + // would expect, and finally verify that ElasticPersistenceProvider's + // return values match what is expected. + it("lists all available documents", function () { + // Not implemented yet + provider.listObjects().then(capture); + expect(capture).toHaveBeenCalledWith([]); + }); + + it("allows object creation", function () { + var model = { someKey: "some value" }; + mockHttp.andReturn(mockPromise({ + data: { "_id": "abc", "_version": 1 } + })); + provider.createObject("testSpace", "abc", model).then(capture); + expect(mockHttp).toHaveBeenCalledWith({ + url: "/test/db/abc", + method: "PUT", + data: model + }); + expect(capture.mostRecentCall.args[0]).toBeTruthy(); + }); + + it("allows object models to be read back", function () { + var model = { someKey: "some value" }; + mockHttp.andReturn(mockPromise({ + data: { "_id": "abc", "_version": 1, "_source": model } + })); + provider.readObject("testSpace", "abc").then(capture); + expect(mockHttp).toHaveBeenCalledWith({ + url: "/test/db/abc", + method: "GET" + }); + expect(capture).toHaveBeenCalledWith(model); + }); + + it("allows object update", function () { + var model = { someKey: "some value" }; + + // First do a read to populate rev tags... + mockHttp.andReturn(mockPromise({ + data: { "_id": "abc", "_version": 42, "_source": {} } + })); + provider.readObject("testSpace", "abc"); + + // Now perform an update + mockHttp.andReturn(mockPromise({ + data: { "_id": "abc", "_version": 43, "_source": {} } + })); + provider.updateObject("testSpace", "abc", model).then(capture); + expect(mockHttp).toHaveBeenCalledWith({ + url: "/test/db/abc", + method: "PUT", + params: { version: 42 }, + data: model + }); + expect(capture.mostRecentCall.args[0]).toBeTruthy(); + }); + + it("allows object deletion", function () { + // First do a read to populate rev tags... + mockHttp.andReturn(mockPromise({ + data: { "_id": "abc", "_version": 42, "_source": {} } + })); + provider.readObject("testSpace", "abc"); + + // Now perform an update + mockHttp.andReturn(mockPromise({ + data: { "_id": "abc", "_version": 42, "_source": {} } + })); + provider.deleteObject("testSpace", "abc", {}).then(capture); + expect(mockHttp).toHaveBeenCalledWith({ + url: "/test/db/abc", + method: "DELETE" + }); + expect(capture.mostRecentCall.args[0]).toBeTruthy(); + }); + + it("returns undefined when objects are not found", function () { + // Act like a 404 + mockHttp.andReturn({ + then: function (success, fail) { + return mockPromise(fail()); + } + }); + provider.readObject("testSpace", "abc").then(capture); + expect(capture).toHaveBeenCalledWith(undefined); + }); + + it("handles rejection due to version", function () { + var model = { someKey: "some value" }, + mockErrorCallback = jasmine.createSpy('error'); + + // First do a read to populate rev tags... + mockHttp.andReturn(mockPromise({ + data: { "_id": "abc", "_version": 42, "_source": {} } + })); + provider.readObject("testSpace", "abc"); + + // Now perform an update + mockHttp.andReturn(mockPromise({ + data: { "status": 409, "error": "Revision error..." } + })); + provider.updateObject("testSpace", "abc", model).then( + capture, + mockErrorCallback + ); + + expect(capture).not.toHaveBeenCalled(); + expect(mockErrorCallback).toHaveBeenCalled(); + }); + + it("handles rejection due to unknown reasons", function () { + var model = { someKey: "some value" }, + mockErrorCallback = jasmine.createSpy('error'); + + // First do a read to populate rev tags... + mockHttp.andReturn(mockPromise({ + data: { "_id": "abc", "_version": 42, "_source": {} } + })); + provider.readObject("testSpace", "abc"); + + // Now perform an update + mockHttp.andReturn(mockPromise({ + data: { "status": 410, "error": "Revision error..." } + })); + provider.updateObject("testSpace", "abc", model).then( + capture, + mockErrorCallback + ); + + expect(capture).not.toHaveBeenCalled(); + expect(mockErrorCallback).toHaveBeenCalled(); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/elastic/test/suite.json b/platform/persistence/elastic/test/suite.json new file mode 100644 index 0000000000..cc8dc2ce0c --- /dev/null +++ b/platform/persistence/elastic/test/suite.json @@ -0,0 +1,4 @@ +[ + "ElasticIndicator", + "ElasticPersistenceProvider" +] diff --git a/platform/persistence/queue/README.md b/platform/persistence/queue/README.md new file mode 100644 index 0000000000..ffd8db4d3e --- /dev/null +++ b/platform/persistence/queue/README.md @@ -0,0 +1,5 @@ +This bundle provides an Overwrite/Cancel dialog when persisting +domain objects, if persistence fails. It is meant to be paired +with a persistence adapter which performs revision-checking +on update calls, in order to provide the user interface for +choosing between Overwrite and Cancel in that situation. \ No newline at end of file diff --git a/platform/persistence/queue/bundle.json b/platform/persistence/queue/bundle.json new file mode 100644 index 0000000000..c67e241e35 --- /dev/null +++ b/platform/persistence/queue/bundle.json @@ -0,0 +1,42 @@ +{ + "extensions": { + "components": [ + { + "type": "decorator", + "provides": "capabilityService", + "implementation": "QueuingPersistenceCapabilityDecorator.js", + "depends": [ "persistenceQueue" ] + } + ], + "services": [ + { + "key": "persistenceQueue", + "implementation": "PersistenceQueue.js", + "depends": [ + "$q", + "$timeout", + "dialogService", + "PERSISTENCE_QUEUE_DELAY" + ] + } + ], + "constants": [ + { + "key": "PERSISTENCE_QUEUE_DELAY", + "value": 5 + } + ], + "templates": [ + { + "key": "persistence-failure-dialog", + "templateUrl": "templates/persistence-failure-dialog.html" + } + ], + "controllers": [ + { + "key": "PersistenceFailureController", + "implementation": "PersistenceFailureController.js" + } + ] + } +} \ No newline at end of file diff --git a/platform/persistence/queue/res/templates/persistence-failure-dialog.html b/platform/persistence/queue/res/templates/persistence-failure-dialog.html new file mode 100644 index 0000000000..ef2423a00c --- /dev/null +++ b/platform/persistence/queue/res/templates/persistence-failure-dialog.html @@ -0,0 +1,31 @@ + + +
+ External changes have been made to the following objects: +
    +
  • + + + was modified at + {{controller.formatTimestamp(failure.error.model.modified)}} + by + {{controller.formatUsername(failure.error.model.modifier)}} +
  • +
+ You may overwrite these objects, or discard your changes to keep + the updates that were made externally. +
+ +
+ Changes to these objects could not be saved for unknown reasons: +
    +
  • + + +
  • +
+
+ +
\ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceFailureConstants.js b/platform/persistence/queue/src/PersistenceFailureConstants.js new file mode 100644 index 0000000000..a130a3b14b --- /dev/null +++ b/platform/persistence/queue/src/PersistenceFailureConstants.js @@ -0,0 +1,8 @@ +/*global define*/ + +define({ + REVISION_ERROR_KEY: "revision", + OVERWRITE_KEY: "overwrite", + TIMESTAMP_FORMAT: "YYYY-MM-DD HH:mm:ss\\Z", + UNKNOWN_USER: "unknown user" +}); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceFailureController.js b/platform/persistence/queue/src/PersistenceFailureController.js new file mode 100644 index 0000000000..551e759c22 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceFailureController.js @@ -0,0 +1,32 @@ +/*global define*/ + +define( + ['moment', './PersistenceFailureConstants'], + function (moment, Constants) { + "use strict"; + + /** + * Controller to support the template to be shown in the + * dialog shown for persistence failures. + */ + function PersistenceFailureController() { + return { + /** + * Format a timestamp for display in the dialog. + */ + formatTimestamp: function (timestamp) { + return moment.utc(timestamp) + .format(Constants.TIMESTAMP_FORMAT); + }, + /** + * Format a user name for display in the dialog. + */ + formatUsername: function (username) { + return username || Constants.UNKNOWN_USER; + } + }; + } + + return PersistenceFailureController; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceFailureDialog.js b/platform/persistence/queue/src/PersistenceFailureDialog.js new file mode 100644 index 0000000000..2840bd1599 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceFailureDialog.js @@ -0,0 +1,54 @@ +/*global define*/ + +define( + ['./PersistenceFailureConstants'], + function (PersistenceFailureConstants) { + "use strict"; + + var OVERWRITE_CANCEL_OPTIONS = [ + { + name: "Overwrite", + key: PersistenceFailureConstants.OVERWRITE_KEY + }, + { + name: "Discard", + key: "cancel" + } + ], + OK_OPTIONS = [ { name: "OK", key: "ok" } ]; + + /** + * Populates a `dialogModel` to pass to `dialogService.getUserChoise` + * in order to choose between Overwrite and Cancel. + */ + function PersistenceFailureDialog(failures) { + var revisionErrors = [], + otherErrors = []; + + // Place this failure into an appropriate group + function categorizeFailure(failure) { + // Check if the error is due to object revision + var isRevisionError = ((failure || {}).error || {}).key === + PersistenceFailureConstants.REVISION_ERROR_KEY; + // Push the failure into the appropriate group + (isRevisionError ? revisionErrors : otherErrors).push(failure); + } + + // Separate into revision errors, and other errors + failures.forEach(categorizeFailure); + + return { + title: "Save Error", + template: "persistence-failure-dialog", + model: { + revised: revisionErrors, + unrecoverable: otherErrors + }, + options: revisionErrors.length > 0 ? + OVERWRITE_CANCEL_OPTIONS : OK_OPTIONS + }; + } + + return PersistenceFailureDialog; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceFailureHandler.js b/platform/persistence/queue/src/PersistenceFailureHandler.js new file mode 100644 index 0000000000..ac5d5a0eca --- /dev/null +++ b/platform/persistence/queue/src/PersistenceFailureHandler.js @@ -0,0 +1,110 @@ +/*global define*/ + +define( + ['./PersistenceFailureDialog', './PersistenceFailureConstants'], + function (PersistenceFailureDialog, PersistenceFailureConstants) { + "use strict"; + + function PersistenceFailureHandler($q, dialogService) { + // Refresh revision information for the domain object associated + // with this persistence failure + function refresh(failure) { + // Refresh the domain object to the latest from persistence + return failure.persistence.refresh(); + } + + // Issue a new persist call for the domain object associated with + // this failure. + function persist(failure) { + // Note that we reissue the persist request here, but don't + // return it, to avoid a circular wait. We trust that the + // PersistenceQueue will behave correctly on the next round + // of flushing. + failure.requeue(); + } + + // Retry persistence (overwrite) for this set of failed attempts + function retry(failures) { + var models = {}; + + // Cache a copy of the model + function cacheModel(failure) { + // Clone... + models[failure.id] = JSON.parse(JSON.stringify( + failure.domainObject.getModel() + )); + } + + // Mutate a domain object to restore its model + function remutate(failure) { + var model = models[failure.id]; + return failure.domainObject.useCapability( + "mutation", + function () { return model; }, + model.modified + ); + } + + // Cache the object models we might want to save + failures.forEach(cacheModel); + + // Strategy here: + // * Cache all of the models we might want to save (above) + // * Refresh all domain objects (so they are latest versions) + // * Re-insert the cached domain object models + // * Invoke persistence again + return $q.all(failures.map(refresh)).then(function () { + return $q.all(failures.map(remutate)); + }).then(function () { + return $q.all(failures.map(persist)); + }); + } + + // Discard changes for a failed refresh + function discard(failure) { + var persistence = + failure.domainObject.getCapability('persistence'); + return persistence.refresh(); + } + + // Discard changes associated with a failed save + function discardAll(failures) { + return $q.all(failures.map(discard)); + } + + // Handle failures in persistence + function handleFailures(failures) { + // Prepare dialog for display + var dialogModel = new PersistenceFailureDialog(failures), + revisionErrors = dialogModel.model.revised; + + // Handle user input (did they choose to overwrite?) + function handleChoice(key) { + // If so, try again + if (key === PersistenceFailureConstants.OVERWRITE_KEY) { + return retry(revisionErrors); + } else { + return discardAll(revisionErrors); + } + } + + // Prompt for user input, the overwrite if they said so. + return dialogService.getUserChoice(dialogModel) + .then(handleChoice, handleChoice); + } + + return { + /** + * Handle persistence failures by providing the user with a + * dialog summarizing these failures, and giving the option + * to overwrite/cancel as appropriate. + * @param {Array} failures persistence failures, as prepared + * by PersistenceQueueHandler + */ + handle: handleFailures + }; + } + + return PersistenceFailureHandler; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceQueue.js b/platform/persistence/queue/src/PersistenceQueue.js new file mode 100644 index 0000000000..13a20479c3 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceQueue.js @@ -0,0 +1,56 @@ +/*global define*/ + +define( + [ + './PersistenceQueueImpl', + './PersistenceQueueHandler', + './PersistenceFailureHandler' + ], + function ( + PersistenceQueueImpl, + PersistenceQueueHandler, + PersistenceFailureHandler + ) { + "use strict"; + + + /** + * The PersistenceQueue is used by the QueuingPersistenceCapability + * to aggregrate calls for object persistence. These are then issued + * in a group, such that if some or all are rejected, this result can + * be shown to the user (again, in a group.) + * + * This constructor is exposed as a service, but handles only the + * wiring of injected dependencies; behavior is implemented in the + * various component parts. + * + * @param $timeout Angular's $timeout + * @param {PersistenceQueueHandler} handler handles actual + * persistence when the queue is flushed + * @param {number} [DELAY] optional; delay in milliseconds between + * attempts to flush the queue + */ + function PersistenceQueue( + $q, + $timeout, + dialogService, + PERSISTENCE_QUEUE_DELAY + ) { + // Wire up injected dependencies + return new PersistenceQueueImpl( + $q, + $timeout, + new PersistenceQueueHandler( + $q, + new PersistenceFailureHandler( + $q, + dialogService + ) + ), + PERSISTENCE_QUEUE_DELAY + ); + } + + return PersistenceQueue; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceQueueHandler.js b/platform/persistence/queue/src/PersistenceQueueHandler.js new file mode 100644 index 0000000000..d56f04b686 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceQueueHandler.js @@ -0,0 +1,90 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Handles actual persistence invocations for queeud persistence + * attempts, in a group. Handling in a group in this manner + * also allows failure to be handled in a group (e.g. in a single + * summary dialog.) + * @param $q Angular's $q, for promises + * @param {PersistenceFailureHandler} handler to invoke in the event + * that a persistence attempt fails. + */ + function PersistenceQueueHandler($q, failureHandler) { + + // Handle a group of persistence invocations + function persistGroup(ids, persistences, domainObjects, queue) { + var failures = []; + + // Try to persist a specific domain object + function tryPersist(id) { + // Look up its persistence capability from the provided + // id->persistence object. + var persistence = persistences[id], + domainObject = domainObjects[id]; + + // Put a domain object back in the queue + // (e.g. after Overwrite) + function requeue() { + return queue.put(domainObject, persistence); + } + + // Handle success + function succeed(value) { + return value; + } + + // Handle failure (build up a list of failures) + function fail(error) { + failures.push({ + id: id, + persistence: persistence, + domainObject: domainObject, + requeue: requeue, + error: error + }); + return false; + } + + // Invoke the actual persistence capability, then + // note success or failures + return persistence.persist().then(succeed, fail); + } + + // Handle any failures from the full operation + function handleFailure(value) { + return failures.length > 0 ? + failureHandler.handle(failures) : + value; + } + + // Try to persist everything, then handle any failures + return $q.all(ids.map(tryPersist)).then(handleFailure); + } + + + return { + /** + * Invoke the persist method on the provided persistence + * capabilities. + * @param {Object.} persistences + * capabilities to invoke, in id->capability pairs. + * @param {Object.} domainObjects + * associated domain objects, in id->object pairs. + * @param {PersistenceQueue} queue the persistence queue, + * to requeue as necessary + */ + persist: function (persistences, domainObjects, queue) { + var ids = Object.keys(persistences); + return persistGroup(ids, persistences, domainObjects, queue); + } + }; + } + + return PersistenceQueueHandler; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/PersistenceQueueImpl.js b/platform/persistence/queue/src/PersistenceQueueImpl.js new file mode 100644 index 0000000000..fdc68e6725 --- /dev/null +++ b/platform/persistence/queue/src/PersistenceQueueImpl.js @@ -0,0 +1,114 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * The PersistenceQueue is used by the QueuingPersistenceCapability + * to aggregrate calls for object persistence. These are then issued + * in a group, such that if some or all are rejected, this result can + * be shown to the user (again, in a group.) + * + * This implementation is separate out from PersistenceQueue, which + * handles the wiring of injected dependencies into an instance of + * this class. + * + * @param $timeout Angular's $timeout + * @param {PersistenceQueueHandler} handler handles actual + * persistence when the queue is flushed + * @param {number} [DELAY] optional; delay in milliseconds between + * attempts to flush the queue + */ + function PersistenceQueueImpl($q, $timeout, handler, DELAY) { + var self, + persistences = {}, + objects = {}, + lastObservedSize = 0, + pendingTimeout, + flushPromise, + activeDefer = $q.defer(); + + // Check if the queue's size has stopped increasing) + function quiescent() { + return Object.keys(persistences).length === lastObservedSize; + } + + // Persist all queued objects + function flush() { + // Get a local reference to the active promise; + // this will be replaced with a promise for the next round + // of persistence calls, so we want to make sure we clear + // the correct one when this flush completes. + var flushingDefer = activeDefer; + + // Clear the active promise for a queue flush + function clearFlushPromise(value) { + flushPromise = undefined; + flushingDefer.resolve(value); + return value; + } + + // Persist all queued objects + flushPromise = handler.persist(persistences, objects, self) + .then(clearFlushPromise, clearFlushPromise); + + // Reset queue, etc. + persistences = {}; + objects = {}; + lastObservedSize = 0; + pendingTimeout = undefined; + activeDefer = $q.defer(); + } + + // Schedule a flushing of the queue (that is, plan to flush + // all objects in the queue) + function scheduleFlush() { + function maybeFlush() { + // Timeout fired, so clear it + pendingTimeout = undefined; + // Only flush when we've stopped receiving updates + (quiescent() ? flush : scheduleFlush)(); + // Update lastObservedSize to detect quiescence + lastObservedSize = Object.keys(persistences).length; + } + + // If we are already flushing the queue... + if (flushPromise) { + // Wait until that's over before considering a flush + flushPromise.then(maybeFlush); + } else { + // Otherwise, schedule a flush on a timeout (to give + // a window for other updates to get aggregated) + pendingTimeout = pendingTimeout || + $timeout(maybeFlush, DELAY, false); + } + + return activeDefer.promise; + } + + // If no delay is provided, use a default + DELAY = DELAY || 0; + + self = { + /** + * Queue persistence of a domain object. + * @param {DomainObject} domainObject the domain object + * @param {PersistenceCapability} persistence the object's + * undecorated persistence capability + */ + put: function (domainObject, persistence) { + var id = domainObject.getId(); + persistences[id] = persistence; + objects[id] = domainObject; + return scheduleFlush(); + } + }; + + return self; + } + + return PersistenceQueueImpl; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/QueuingPersistenceCapability.js b/platform/persistence/queue/src/QueuingPersistenceCapability.js new file mode 100644 index 0000000000..a4d94a4acc --- /dev/null +++ b/platform/persistence/queue/src/QueuingPersistenceCapability.js @@ -0,0 +1,30 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * The QueuingPersistenceCapability places `persist` calls in a queue + * to be handled in batches. + * @param {PersistenceQueue} queue of persistence calls + * @param {PersistenceCapability} persistence the wrapped persistence + * capability + * @param {DomainObject} domainObject the domain object which exposes + * the capability + */ + function QueuingPersistenceCapability(queue, persistence, domainObject) { + var queuingPersistence = Object.create(persistence); + + // Override persist calls to queue them instead + queuingPersistence.persist = function () { + return queue.put(domainObject, persistence); + }; + + return queuingPersistence; + } + + return QueuingPersistenceCapability; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/src/QueuingPersistenceCapabilityDecorator.js b/platform/persistence/queue/src/QueuingPersistenceCapabilityDecorator.js new file mode 100644 index 0000000000..40d0f0c470 --- /dev/null +++ b/platform/persistence/queue/src/QueuingPersistenceCapabilityDecorator.js @@ -0,0 +1,76 @@ +/*global define,Promise*/ + +/** + * Module defining CoreCapabilityProvider. Created by vwoeltje on 11/7/14. + */ +define( + ['./QueuingPersistenceCapability'], + function (QueuingPersistenceCapability) { + "use strict"; + + /** + * Capability decorator. Adds queueing support to persistence + * capabilities for domain objects, such that persistence attempts + * will be handled in batches (allowing failure notification to + * also be presented in batches.) + * + * @constructor + */ + function QueuingPersistenceCapabilityDecorator( + persistenceQueue, + capabilityService + ) { + + function decoratePersistence(capabilities) { + var originalPersistence = capabilities.persistence; + if (originalPersistence) { + capabilities.persistence = function (domainObject) { + // Get/instantiate the original + var original = + (typeof originalPersistence === 'function') ? + originalPersistence(domainObject) : + originalPersistence; + + // Provide a decorated version + return new QueuingPersistenceCapability( + persistenceQueue, + original, + domainObject + ); + }; + } + return capabilities; + } + + function getCapabilities(model) { + return decoratePersistence( + capabilityService.getCapabilities(model) + ); + } + + return { + /** + * Get all capabilities associated with a given domain + * object. + * + * This returns a promise for an object containing key-value + * pairs, where keys are capability names and values are + * either: + * + * * Capability instances + * * Capability constructors (which take a domain object + * as their argument.) + * + * + * @param {*} model the object model + * @returns {Object.} all + * capabilities known to be valid for this model, as + * key-value pairs + */ + getCapabilities: getCapabilities + }; + } + + return QueuingPersistenceCapabilityDecorator; + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceFailureConstantsSpec.js b/platform/persistence/queue/test/PersistenceFailureConstantsSpec.js new file mode 100644 index 0000000000..475880facd --- /dev/null +++ b/platform/persistence/queue/test/PersistenceFailureConstantsSpec.js @@ -0,0 +1,16 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/PersistenceFailureConstants"], + function (PersistenceFailureConstants) { + "use strict"; + + describe("Persistence failure constants", function () { + it("defines an overwrite key", function () { + expect(PersistenceFailureConstants.OVERWRITE_KEY) + .toEqual(jasmine.any(String)); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceFailureControllerSpec.js b/platform/persistence/queue/test/PersistenceFailureControllerSpec.js new file mode 100644 index 0000000000..6592d880e3 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceFailureControllerSpec.js @@ -0,0 +1,27 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/PersistenceFailureController"], + function (PersistenceFailureController) { + "use strict"; + + describe("The persistence failure controller", function () { + var controller; + + beforeEach(function () { + controller = new PersistenceFailureController(); + }); + + it("converts timestamps to human-readable dates", function () { + expect(controller.formatTimestamp(402514331000)) + .toEqual("1982-10-03 17:32:11Z"); + }); + + it("provides default user names", function () { + expect(controller.formatUsername(undefined)) + .toEqual(jasmine.any(String)); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceFailureDialogSpec.js b/platform/persistence/queue/test/PersistenceFailureDialogSpec.js new file mode 100644 index 0000000000..9de400be79 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceFailureDialogSpec.js @@ -0,0 +1,38 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/PersistenceFailureDialog", "../src/PersistenceFailureConstants"], + function (PersistenceFailureDialog, Constants) { + "use strict"; + + describe("The persistence failure dialog", function () { + var testFailures, + dialog; + + beforeEach(function () { + testFailures = [ + { error: { key: Constants.REVISION_ERROR_KEY }, someKey: "abc" }, + { error: { key: "..." }, someKey: "def" }, + { error: { key: Constants.REVISION_ERROR_KEY }, someKey: "ghi" }, + { error: { key: Constants.REVISION_ERROR_KEY }, someKey: "jkl" }, + { error: { key: "..." }, someKey: "mno" } + ]; + dialog = new PersistenceFailureDialog(testFailures); + }); + + it("categorizes failures", function () { + expect(dialog.model.revised).toEqual([ + testFailures[0], testFailures[2], testFailures[3] + ]); + expect(dialog.model.unrecoverable).toEqual([ + testFailures[1], testFailures[4] + ]); + }); + + it("provides an overwrite option", function () { + expect(dialog.options[0].key).toEqual(Constants.OVERWRITE_KEY); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceFailureHandlerSpec.js b/platform/persistence/queue/test/PersistenceFailureHandlerSpec.js new file mode 100644 index 0000000000..2dd910619f --- /dev/null +++ b/platform/persistence/queue/test/PersistenceFailureHandlerSpec.js @@ -0,0 +1,97 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/PersistenceFailureHandler", "../src/PersistenceFailureConstants"], + function (PersistenceFailureHandler, Constants) { + "use strict"; + + describe("The persistence failure handler", function () { + var mockQ, + mockDialogService, + mockFailures, + mockPromise, + handler; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + function makeMockFailure(id, index) { + var mockFailure = jasmine.createSpyObj( + 'failure-' + id, + ['requeue'] + ), + mockPersistence = jasmine.createSpyObj( + 'persistence-' + id, + ['refresh', 'persist'] + ); + mockFailure.domainObject = jasmine.createSpyObj( + 'domainObject', + ['getCapability', 'useCapability', 'getModel'] + ); + mockFailure.domainObject.getCapability.andCallFake(function (c) { + return (c === 'persistence') && mockPersistence; + }); + mockFailure.domainObject.getModel.andReturn({ id: id, modified: index }); + mockFailure.persistence = mockPersistence; + mockFailure.id = id; + mockFailure.error = { key: Constants.REVISION_ERROR_KEY }; + return mockFailure; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['all', 'when']); + mockDialogService = jasmine.createSpyObj('dialogService', ['getUserChoice']); + mockFailures = ['a', 'b', 'c'].map(makeMockFailure); + mockPromise = jasmine.createSpyObj('promise', ['then']); + mockDialogService.getUserChoice.andReturn(mockPromise); + mockQ.all.andReturn(mockPromise); + mockPromise.then.andReturn(mockPromise); + handler = new PersistenceFailureHandler(mockQ, mockDialogService); + }); + + it("shows a dialog to handle failures", function () { + handler.handle(mockFailures); + expect(mockDialogService.getUserChoice).toHaveBeenCalled(); + }); + + it("overwrites on request", function () { + mockQ.all.andReturn(asPromise([])); + handler.handle(mockFailures); + // User chooses overwrite + mockPromise.then.mostRecentCall.args[0](Constants.OVERWRITE_KEY); + // Should refresh, remutate, and requeue all objects + mockFailures.forEach(function (mockFailure, i) { + expect(mockFailure.persistence.refresh).toHaveBeenCalled(); + expect(mockFailure.requeue).toHaveBeenCalled(); + expect(mockFailure.domainObject.useCapability).toHaveBeenCalledWith( + 'mutation', + jasmine.any(Function), + i // timestamp + ); + expect(mockFailure.domainObject.useCapability.mostRecentCall.args[1]()) + .toEqual({ id: mockFailure.id, modified: i }); + }); + }); + + it("discards on request", function () { + mockQ.all.andReturn(asPromise([])); + handler.handle(mockFailures); + // User chooses overwrite + mockPromise.then.mostRecentCall.args[0](false); + // Should refresh, but not remutate, and requeue all objects + mockFailures.forEach(function (mockFailure, i) { + expect(mockFailure.persistence.refresh).toHaveBeenCalled(); + expect(mockFailure.requeue).not.toHaveBeenCalled(); + expect(mockFailure.domainObject.useCapability).not.toHaveBeenCalled(); + }); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceQueueHandlerSpec.js b/platform/persistence/queue/test/PersistenceQueueHandlerSpec.js new file mode 100644 index 0000000000..8a975b1273 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceQueueHandlerSpec.js @@ -0,0 +1,116 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/PersistenceQueueHandler"], + function (PersistenceQueueHandler) { + "use strict"; + + var TEST_ERROR = { someKey: "some value" }; + + describe("The persistence queue handler", function () { + var mockQ, + mockFailureHandler, + mockPersistences, + mockDomainObjects, + mockQueue, + mockRejection, + handler; + + function asPromise(value) { + return (value || {}).then ? value : { + then: function (callback) { + return asPromise(callback(value)); + } + }; + } + + function makeMockPersistence(id) { + var mockPersistence = jasmine.createSpyObj( + 'persistence-' + id, + [ 'persist', 'refresh' ] + ); + mockPersistence.persist.andReturn(asPromise(true)); + return mockPersistence; + } + + function makeMockDomainObject(id) { + var mockDomainObject = jasmine.createSpyObj( + 'domainObject-' + id, + [ 'getId' ] + ); + mockDomainObject.getId.andReturn(id); + return mockDomainObject; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['all']); + mockFailureHandler = jasmine.createSpyObj('handler', ['handle']); + mockQueue = jasmine.createSpyObj('queue', ['put']); + mockPersistences = {}; + mockDomainObjects = {}; + ['a', 'b', 'c'].forEach(function (id) { + mockPersistences[id] = makeMockPersistence(id); + mockDomainObjects[id] = makeMockDomainObject(id); + }); + mockRejection = jasmine.createSpyObj('rejection', ['then']); + mockQ.all.andReturn(asPromise([])); + mockRejection.then.andCallFake(function (callback, fallback) { + return asPromise(fallback({ someKey: "some value" })); + }); + handler = new PersistenceQueueHandler(mockQ, mockFailureHandler); + }); + + it("invokes persistence on all members in the group", function () { + handler.persist(mockPersistences, mockDomainObjects, mockQueue); + expect(mockPersistences.a.persist).toHaveBeenCalled(); + expect(mockPersistences.b.persist).toHaveBeenCalled(); + expect(mockPersistences.c.persist).toHaveBeenCalled(); + // No failures in this group + expect(mockFailureHandler.handle).not.toHaveBeenCalled(); + }); + + it("handles failures that occur", function () { + mockPersistences.b.persist.andReturn(mockRejection); + mockPersistences.c.persist.andReturn(mockRejection); + handler.persist(mockPersistences, mockDomainObjects, mockQueue); + expect(mockFailureHandler.handle).toHaveBeenCalledWith([ + { + id: 'b', + persistence: mockPersistences.b, + domainObject: mockDomainObjects.b, + requeue: jasmine.any(Function), + error: TEST_ERROR + }, + { + id: 'c', + persistence: mockPersistences.c, + domainObject: mockDomainObjects.c, + requeue: jasmine.any(Function), + error: TEST_ERROR + } + ]); + }); + + it("provides a requeue method for failures", function () { + // This method is needed by PersistenceFailureHandler + // to allow requeuing of objects for persistence when + // Overwrite is chosen. + mockPersistences.b.persist.andReturn(mockRejection); + handler.persist(mockPersistences, mockDomainObjects, mockQueue); + + // Verify precondition + expect(mockQueue.put).not.toHaveBeenCalled(); + + // Invoke requeue + mockFailureHandler.handle.mostRecentCall.args[0][0].requeue(); + + // Should have returned the object to the queue + expect(mockQueue.put).toHaveBeenCalledWith( + mockDomainObjects.b, + mockPersistences.b + ); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceQueueImplSpec.js b/platform/persistence/queue/test/PersistenceQueueImplSpec.js new file mode 100644 index 0000000000..8bc4687ea5 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceQueueImplSpec.js @@ -0,0 +1,133 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/PersistenceQueueImpl"], + function (PersistenceQueueImpl) { + "use strict"; + + var TEST_DELAY = 42; + + describe("The implemented persistence queue", function () { + var mockQ, + mockTimeout, + mockHandler, + mockDeferred, + mockPromise, + queue; + + function makeMockDomainObject(id) { + var mockDomainObject = jasmine.createSpyObj( + 'domainObject-' + id, + [ 'getId' ] + ); + mockDomainObject.getId.andReturn(id); + return mockDomainObject; + } + + function makeMockPersistence(id) { + var mockPersistence = jasmine.createSpyObj( + 'persistence-' + id, + [ 'persist' ] + ); + return mockPersistence; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['when', 'defer']); + mockTimeout = jasmine.createSpy('$timeout'); + mockHandler = jasmine.createSpyObj('handler', ['persist']); + mockDeferred = jasmine.createSpyObj('deferred', ['resolve']); + mockDeferred.promise = jasmine.createSpyObj('promise', ['then']); + mockPromise = jasmine.createSpyObj('promise', ['then']); + mockQ.defer.andReturn(mockDeferred); + mockTimeout.andReturn({}); + mockHandler.persist.andReturn(mockPromise); + mockPromise.then.andReturn(mockPromise); + queue = new PersistenceQueueImpl( + mockQ, + mockTimeout, + mockHandler, + TEST_DELAY + ); + }); + + it("schedules a timeout to persist objects", function () { + expect(mockTimeout).not.toHaveBeenCalled(); + queue.put(makeMockDomainObject('a'), makeMockPersistence('a')); + expect(mockTimeout).toHaveBeenCalledWith( + jasmine.any(Function), + TEST_DELAY, + false + ); + }); + + it("does not schedule multiple timeouts for multiple objects", function () { + // Put three objects in without triggering the timeout; + // shouldn't schedule multiple timeouts + queue.put(makeMockDomainObject('a'), makeMockPersistence('a')); + queue.put(makeMockDomainObject('b'), makeMockPersistence('b')); + queue.put(makeMockDomainObject('c'), makeMockPersistence('c')); + expect(mockTimeout.calls.length).toEqual(1); + }); + + it("returns a promise", function () { + expect(queue.put(makeMockDomainObject('a'), makeMockPersistence('a'))) + .toEqual(mockDeferred.promise); + }); + + it("waits for quiescence to proceed", function () { + // Keep adding objects to the queue between timeouts. + // Should keep scheduling timeouts instead of resolving. + queue.put(makeMockDomainObject('a'), makeMockPersistence('a')); + expect(mockTimeout.calls.length).toEqual(1); + mockTimeout.mostRecentCall.args[0](); + queue.put(makeMockDomainObject('b'), makeMockPersistence('b')); + expect(mockTimeout.calls.length).toEqual(2); + mockTimeout.mostRecentCall.args[0](); + queue.put(makeMockDomainObject('c'), makeMockPersistence('c')); + expect(mockTimeout.calls.length).toEqual(3); + mockTimeout.mostRecentCall.args[0](); + expect(mockHandler.persist).not.toHaveBeenCalled(); + }); + + it("persists upon quiescence", function () { + // Add objects to the queue, but fire two timeouts afterward + queue.put(makeMockDomainObject('a'), makeMockPersistence('a')); + queue.put(makeMockDomainObject('b'), makeMockPersistence('b')); + queue.put(makeMockDomainObject('c'), makeMockPersistence('c')); + mockTimeout.mostRecentCall.args[0](); + mockTimeout.mostRecentCall.args[0](); + expect(mockHandler.persist).toHaveBeenCalled(); + }); + + it("waits on an active flush, while flushing", function () { + // Persist some objects + queue.put(makeMockDomainObject('a'), makeMockPersistence('a')); + queue.put(makeMockDomainObject('b'), makeMockPersistence('b')); + mockTimeout.mostRecentCall.args[0](); + mockTimeout.mostRecentCall.args[0](); + expect(mockTimeout.calls.length).toEqual(2); + // Adding a new object should not trigger a new timeout, + // because we haven't completed the previous flush + queue.put(makeMockDomainObject('c'), makeMockPersistence('c')); + expect(mockTimeout.calls.length).toEqual(2); + }); + + it("clears the active flush after it has completed", function () { + // Persist some objects + queue.put(makeMockDomainObject('a'), makeMockPersistence('a')); + queue.put(makeMockDomainObject('b'), makeMockPersistence('b')); + mockTimeout.mostRecentCall.args[0](); + mockTimeout.mostRecentCall.args[0](); + expect(mockTimeout.calls.length).toEqual(2); + // Resolve the promise from handler.persist + mockPromise.then.calls[0].args[0](true); + // Adding a new object should now trigger a new timeout, + // because we have completed the previous flush + queue.put(makeMockDomainObject('c'), makeMockPersistence('c')); + expect(mockTimeout.calls.length).toEqual(3); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/PersistenceQueueSpec.js b/platform/persistence/queue/test/PersistenceQueueSpec.js new file mode 100644 index 0000000000..8f05b36a06 --- /dev/null +++ b/platform/persistence/queue/test/PersistenceQueueSpec.js @@ -0,0 +1,35 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/PersistenceQueue"], + function (PersistenceQueue) { + "use strict"; + + describe("The persistence queue", function () { + var mockQ, + mockTimeout, + mockDialogService, + queue; + + beforeEach(function () { + mockQ = jasmine.createSpyObj("$q", ['defer']); + mockTimeout = jasmine.createSpy("$timeout"); + mockDialogService = jasmine.createSpyObj( + 'dialogService', + ['getUserChoice'] + ); + queue = new PersistenceQueue(mockQ, mockTimeout, mockDialogService); + }); + + // PersistenceQueue is just responsible for handling injected + // dependencies and wiring the PersistenceQueueImpl and its + // handlers. Functionality is tested there, so our test here is + // minimal (get back expected interface, no exceptions) + it("provides a queue with a put method", function () { + expect(queue.put).toEqual(jasmine.any(Function)); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/QueuingPersistenceCapabilityDecoratorSpec.js b/platform/persistence/queue/test/QueuingPersistenceCapabilityDecoratorSpec.js new file mode 100644 index 0000000000..cd1ef01f5f --- /dev/null +++ b/platform/persistence/queue/test/QueuingPersistenceCapabilityDecoratorSpec.js @@ -0,0 +1,63 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/QueuingPersistenceCapabilityDecorator"], + function (QueuingPersistenceCapabilityDecorator) { + "use strict"; + + describe("A queuing persistence capability decorator", function () { + var mockQueue, + mockCapabilityService, + mockPersistenceConstructor, + mockPersistence, + mockDomainObject, + testModel, + decorator; + + beforeEach(function () { + mockQueue = jasmine.createSpyObj('queue', ['put']); + mockCapabilityService = jasmine.createSpyObj( + 'capabilityService', + ['getCapabilities'] + ); + testModel = { someKey: "some value" }; + mockPersistence = jasmine.createSpyObj( + 'persistence', + ['persist', 'refresh'] + ); + mockPersistenceConstructor = jasmine.createSpy(); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + ['getId'] + ); + + mockCapabilityService.getCapabilities.andReturn({ + persistence: mockPersistenceConstructor + }); + mockPersistenceConstructor.andReturn(mockPersistence); + + decorator = new QueuingPersistenceCapabilityDecorator( + mockQueue, + mockCapabilityService + ); + }); + + // Here, we verify that the decorator wraps the calls it is expected + // to wrap; remaining responsibility belongs to + // QueuingPersistenceCapability itself, which has its own tests. + + it("delegates to its wrapped service", function () { + decorator.getCapabilities(testModel); + expect(mockCapabilityService.getCapabilities) + .toHaveBeenCalledWith(testModel); + }); + + it("wraps its persistence capability's constructor", function () { + decorator.getCapabilities(testModel).persistence(mockDomainObject); + expect(mockPersistenceConstructor).toHaveBeenCalledWith(mockDomainObject); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/QueuingPersistenceCapabilitySpec.js b/platform/persistence/queue/test/QueuingPersistenceCapabilitySpec.js new file mode 100644 index 0000000000..e383bd77ba --- /dev/null +++ b/platform/persistence/queue/test/QueuingPersistenceCapabilitySpec.js @@ -0,0 +1,46 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/QueuingPersistenceCapability"], + function (QueuingPersistenceCapability) { + "use strict"; + + describe("A queuing persistence capability", function () { + var mockQueue, + mockPersistence, + mockDomainObject, + persistence; + + beforeEach(function () { + mockQueue = jasmine.createSpyObj('queue', ['put']); + mockPersistence = jasmine.createSpyObj( + 'persistence', + ['persist', 'refresh'] + ); + mockDomainObject = {}; + persistence = new QueuingPersistenceCapability( + mockQueue, + mockPersistence, + mockDomainObject + ); + }); + + it("puts a request for persistence into the queue on persist", function () { + // Verify precondition + expect(mockQueue.put).not.toHaveBeenCalled(); + // Invoke persistence + persistence.persist(); + // Should have queued + expect(mockQueue.put).toHaveBeenCalledWith( + mockDomainObject, + mockPersistence + ); + }); + + it("exposes other methods from the wrapped persistence capability", function () { + expect(persistence.refresh).toBe(mockPersistence.refresh); + }); + }); + } +); \ No newline at end of file diff --git a/platform/persistence/queue/test/suite.json b/platform/persistence/queue/test/suite.json new file mode 100644 index 0000000000..3c32be4155 --- /dev/null +++ b/platform/persistence/queue/test/suite.json @@ -0,0 +1,11 @@ +[ + "PersistenceFailureConstants", + "PersistenceFailureController", + "PersistenceFailureDialog", + "PersistenceFailureHandler", + "PersistenceQueue", + "PersistenceQueueHandler", + "PersistenceQueueImpl", + "QueuingPersistenceCapability", + "QueuingPersistenceCapabilityDecorator" +] \ No newline at end of file