← Back to team overview

yellow team mailing list archive

[Merge] lp:~gary/juju-gui/charmdivision into lp:juju-gui

 

Gary Poster has proposed merging lp:~gary/juju-gui/charmdivision into lp:juju-gui.

Requested reviews:
  Juju GUI Hackers (juju-gui)

For more details, see:
https://code.launchpad.net/~gary/juju-gui/charmdivision/+merge/130464

Separate environment and store charms

On discussion with Kapil, a significant problem from a previous refactoring of mine became clear.  Charm ids are only reliably unique within a given context, such as juju environment vs charm store.  Therefore, charms from the two sources need to be stored separately.

We discussed some approaches.  He suggested storing environment charms in the database and store charms in the browser, and I liked that idea.  He also requested that I factor out the charm model code into a separate file, even while keeping it in the juju.models package.  Finally, he suggested that charm ids should always include revisions in order to guarantee uniqueness, and to make it possible to consider whether charms from the different sources are identical.

I wrote a long explanation of this in the app/models/charms.js file, including additional considerations.

The charm store needed to send the revision itself, which Kapil changed it to do.

I ended up also factoring out a charm store object, with the ability to get the data from a search, organized as we like it; and to get the data for a specific charm.  A nice fall out from the tests of this code is that it exposed some pre-existing problems with sorting code, which I addressed.

The change is quite large because it touches so many of the files.  On the bright side, many of the test changes are mechanical, and much of the code is moved and only slightly refactored and changed from other sources, so deletions and moves account for much of the churn.  That said, it is still a large branch, for which I apologize.

Thanks,

Gary

https://codereview.appspot.com/6749046/

-- 
https://code.launchpad.net/~gary/juju-gui/charmdivision/+merge/130464
Your team Juju GUI Hackers is requested to review the proposed merge of lp:~gary/juju-gui/charmdivision into lp:juju-gui.
=== modified file '.jshintrc'
--- .jshintrc	2012-10-02 11:22:07 +0000
+++ .jshintrc	2012-10-19 02:11:22 +0000
@@ -118,6 +118,7 @@
     "beforeEach",
     "d3",
     "describe",
+    "escape",
     "expect",
     "GlobalConfig",
     "consoleManager",

=== modified file 'app/app.js'
--- app/app.js	2012-10-14 00:38:05 +0000
+++ app/app.js	2012-10-19 02:11:22 +0000
@@ -121,10 +121,11 @@
       }
       // Create a charm store.
       if (this.get('charm_store')) {
+        // This path is for tests.
         this.charm_store = this.get('charm_store');
       } else {
-        this.charm_store = new Y.DataSource.IO({
-          source: this.get('charm_store_url')});
+        this.charm_store = new juju.CharmStore({
+          datasource: this.get('charm_store_url')});
       }
       // Create notifications controller
       this.notifications = new juju.NotificationController({
@@ -291,8 +292,7 @@
         var charm_id = service.get('charm'),
             self = this;
         if (!Y.Lang.isValue(this.db.charms.getById(charm_id))) {
-          this.db.charms.add({id: charm_id}).load(
-              {env: this.env, charm_store: this.charm_store},
+          this.db.charms.add({id: charm_id}).load(this.env,
               // If views are bound to the charm model, firing "update" is
               // unnecessary, and potentially even mildly harmful.
               function(err, result) { self.db.fire('update'); });
@@ -613,5 +613,6 @@
     'base',
     'node',
     'model',
-    'juju-charm-search']
+    'juju-charm-search',
+    'juju-charm-store']
 });

=== added file 'app/models/charm.js'
--- app/models/charm.js	1970-01-01 00:00:00 +0000
+++ app/models/charm.js	2012-10-19 02:11:22 +0000
@@ -0,0 +1,310 @@
+'use strict';
+
+YUI.add('juju-charm-models', function(Y) {
+
+  var models = Y.namespace('juju.models');
+
+  // This is how the charm_id_re regex works for various inputs.  The first
+  // element is always the initial string, which we have elided in the
+  // examples.
+  // 'cs:~marcoceppi/precise/word-press-17' ->
+  // [..."cs", "marcoceppi", "precise", "word-press", "17"]
+  // 'cs:~marcoceppi/precise/word-press' ->
+  // [..."cs", "marcoceppi", "precise", "word-press", undefined]
+  // 'cs:precise/word-press' ->
+  // [..."cs", undefined, "precise", "word-press", undefined]
+  // 'cs:precise/word-press-17'
+  // [..."cs", undefined, "precise", "word-press", "17"]
+  var charm_id_re = /^(?:(\w+):)?(?:~(\S+)\/)?(\w+)\/(\S+?)(?:-(\d+))?$/,
+      parse_charm_id = function(id) {
+        var parts = charm_id_re.exec(id),
+            result = {};
+        if (parts) {
+          parts.shift();
+          Y.each(
+              Y.Array.zip(
+                  ['scheme', 'owner', 'series', 'package_name', 'revision'],
+                  parts),
+              function(pair) { result[pair[0]] = pair[1]; });
+          if (!Y.Lang.isValue(result.scheme)) {
+            result.scheme = 'cs'; // This is the default.
+          }
+          return result;
+        }
+        // return undefined;
+      },
+      _calculate_full_charm_name = function(elements) {
+        var tmp = [elements.series, elements.package_name];
+        if (elements.owner) {
+          tmp.unshift('~' + elements.owner);
+        }
+        return tmp.join('/');
+      },
+      _calculate_charm_store_path = function(elements) {
+        return [(elements.owner ? '~' + elements.owner : 'charms'),
+                elements.series, elements.package_name, 'json'].join('/');
+      },
+      _calculate_base_charm_id = function(elements) {
+        return elements.scheme + ':' + _calculate_full_charm_name(elements);
+      },
+      _reconsititute_charm_id = function(elements) {
+        return _calculate_base_charm_id(elements) + '-' + elements.revision;
+      },
+      _clean_charm_data = function(data) {
+        data.is_subordinate = data.subordinate;
+        Y.each(['subordinate', 'name', 'revision', 'store_revision'],
+               function(nm) { delete data[nm]; });
+        return data;
+      };
+  // This is exposed for testing purposes.
+  models.parse_charm_id = parse_charm_id;
+
+  // For simplicity and uniformity, there is a single Charm class and a
+  // single CharmList class. Charms, once instantiated and loaded with data
+  // from their respective sources, are immutable and read-only. This reflects
+  // the reality of how we interact with them.
+
+  // Charm instances can represent both environment charms and charm store
+  // charms.  A charm id is reliably and uniquely associated with a given
+  // charm only within a given context--the environment or the charm store.
+
+  // Therefore, the database keeps these charms separate in two different
+  // CharmList instances.  One is db.charms, representing the environment
+  // charms.  The other is maintained by and within the persistent charm panel
+  // instance. As you'd expect, environment charms are what to use when
+  // viewing or manipulating the environment.  Charm store charms are what we
+  // can browse to select and deploy new charms to the environment.
+
+  // Environment charms begin their lives with full charm ids, as provided by
+  // services in the environment:
+
+  // [SCHEME]:(~[OWNER]/)?[SERIES]/[PACKAGE NAME]-[REVISION].
+
+  // With an id, we can instantiate a charm: typically we use
+  // "db.charms.add({id: [ID]})".  Finally, we load the charm's data from the
+  // environment using the standard YUI Model method "load," providing an
+  // object with a get_charm callable, and an optional callback (see YUI
+  // docs).  The env has a get_charm method, so, by design, it works nicely:
+  // "charm.load(env, optionalCallback)".  The get_charm method is expected to
+  // return what the env version does: either an object with a "result" object
+  // containing the charm data, or an object with an "err" attribute.
+
+  // The charms in the charm store have a significant difference, beyond the
+  // source of their data: they are addressed in the charm store by a path
+  // that does not include the revision number, and charm store searches do
+  // not include revision numbers in the results.  Therefore, we cannot
+  // immediately instantiate a charm, because it requires a full id in order
+  // to maintain the idea of an immutable charm associated with a unique charm
+  // id.  However, the charm information that returns does have a revision
+  // number (the most recent); moreover, over time the charm may be updated,
+  // leading to a new charm revision.  We model this by creating a new charm.
+
+  // Since we cannot create or search for charms without a revision number
+  // using the normal methods, the charm list has a couple of helpers for this
+  // story.  The workhorse is "loadOneByBaseId".  A "base id" is an id without
+  // a revision.
+
+  // The arguments to "loadOneById" are a base id and a hash of other options.
+  // The hash must have a "charm_store" attribute, that itself loadByPath
+  // method, like the one in app/store/charm.js.  It may have zero or more of
+  // the following: a success callback, accepting the fully loaded charm with
+  // the newest revision for the given base id; a failure callback, accepting
+  // the Y.io response object after a failure; and a "force" attribute that,
+  // if it is a Javascript boolean truth-y value, forces a load even if a
+  // charm with the given id already is in the charm list.
+
+  // "getOneByBaseId" simply returns the charm with the highest revision and
+  // "the given base id from the charm list, without trying to load
+  // "information.
+
+  // In both cases, environment charms and charm store charms, a charm's
+  // "loaded" attribute is set to true once it has all the data from its
+  // environment.
+
+  var Charm = Y.Base.create('charm', Y.Model, [], {
+    initializer: function() {
+      this.loaded = false;
+      this.on('load', function() { this.loaded = true; });
+    },
+    sync: function(action, options, callback) {
+      if (action !== 'read') {
+        throw (
+            'Only use the "read" action; "' + action + '" not supported.');
+      }
+      if (!Y.Lang.isValue(options.get_charm)) {
+        throw 'You must supply a get_charm function.';
+      }
+      options.get_charm(
+          this.get('id'),
+          // This is the success callback, or the catch-all callback for
+          // get_charm.
+          function(response) {
+            // Handle the env.get_charm response specially, for ease of use.  If
+            // it doesn't match that pattern, pass it through.
+            if (response.err) {
+              callback(true, response);
+            } else if (response.result) {
+              callback(false, response.result);
+            } else { // This would typically be a string.
+              callback(false, response);
+            }
+          },
+          // This is the optional error callback.
+          function(response) {
+            callback(true, response);
+          }
+      );
+    },
+    parse: function() {
+      return _clean_charm_data(Charm.superclass.parse.apply(this, arguments));
+    }
+  }, {
+    ATTRS: {
+      id: {
+        lazyAdd: false,
+        setter: function(val) {
+          if (!val) {
+            return val;
+          }
+          var parts = parse_charm_id(val),
+              self = this;
+          parts.revision = parseInt(parts.revision, 10);
+          Y.each(parts, function(value, key) {
+            self._set(key, value);
+          });
+          this._set(
+              'charm_store_path', _calculate_charm_store_path(parts));
+          this._set('full_name', _calculate_full_charm_name(parts));
+          return _reconsititute_charm_id(parts);
+        },
+        validator: function(val) {
+          var parts = parse_charm_id(val);
+          return (parts && Y.Lang.isValue(parts.revision));
+        }
+      },
+      // All of the below are loaded except as noted.
+      bzr_branch: {writeOnce: true},
+      charm_store_path: {readOnly: true}, // calculated
+      config: {writeOnce: true},
+      description: {writeOnce: true},
+      full_name: {readOnly: true}, // calculated
+      is_subordinate: {writeOnce: true},
+      last_change:
+          { writeOnce: true,
+            setter: function(val) {
+              // Normalize created value from float to date object.
+              if (val && val.created) {
+                // Mutating in place should be fine since this should only
+                // come from loading over the wire.
+                val.created = new Date(val.created * 1000);
+              }
+              return val;
+            }
+          },
+      maintainer: {writeOnce: true},
+      metadata: {writeOnce: true},
+      package_name: {readOnly: true}, // calculated
+      owner: {readOnly: true}, // calculated
+      peers: {writeOnce: true},
+      proof: {writeOnce: true},
+      provides: {writeOnce: true},
+      requires: {writeOnce: true},
+      revision: {readOnly: true}, // calculated
+      scheme: {readOnly: true}, // calculated
+      series: {readOnly: true}, // calculated
+      summary: {writeOnce: true},
+      url: {writeOnce: true}
+    }
+  });
+  models.Charm = Charm;
+
+  var CharmList = Y.Base.create('charmList', Y.ModelList, [], {
+    model: Charm,
+
+    initializer: function() {
+      this._baseIdHash = {}; // base id (without revision) to array of charms.
+    },
+
+    _addToBaseIdHash: function(charm) {
+      var baseId = charm.get('scheme') + ':' + charm.get('full_name'),
+          matches = this._baseIdHash[baseId];
+      if (!matches) {
+        matches = this._baseIdHash[baseId] = [];
+      }
+      matches.push(charm);
+      // Note that we don't handle changing baseIds or removed charms because
+      // that should not happen.
+      // Sort on newest charms first.
+      matches.sort(function(a, b) {
+        var revA = parseInt(a.get('revision'), 10),
+            revB = parseInt(b.get('revision'), 10);
+        return revB - revA;
+      });
+    },
+
+    add: function() {
+      var result = CharmList.superclass.add.apply(this, arguments);
+      if (Y.Lang.isArray(result)) {
+        Y.each(result, this._addToBaseIdHash, this);
+      } else {
+        this._addToBaseIdHash(result);
+      }
+      return result;
+    },
+
+    getOneByBaseId: function(id) {
+      var match = parse_charm_id(id),
+          baseId = match && _calculate_base_charm_id(match),
+          charms = baseId && this._baseIdHash[baseId];
+      return charms && charms[0];
+    },
+
+    loadOneByBaseId: function(id, options) {
+      var match = parse_charm_id(id);
+      if (match) {
+        if (!options.force) {
+          var charm = this.getOneByBaseId(_calculate_base_charm_id(match));
+          if (charm) {
+            if (options.success) {
+              options.success(charm);
+            }
+            return;
+          }
+        }
+        var path = _calculate_charm_store_path(match),
+            self = this;
+        options.charm_store.loadByPath(
+            path,
+            { success: function(data) {
+              // We fall back to 0 for revision.  Some records do not have one
+              // still in the charm store, such as
+              // http://jujucharms.com/charms/precise/appflower/json (as of this
+              // writing).
+              match.revision = data.store_revision || 0;
+              id = _reconsititute_charm_id(match);
+              charm = self.getById(id);
+              if (!charm) {
+                charm = self.add({id: id});
+                charm.setAttrs(_clean_charm_data(data));
+                charm.loaded = true;
+              }
+              if (options.success) {
+                options.success(charm);
+              }
+            },
+            failure: options.failure });
+      } else {
+        throw id + ' is not a valid base charm id';
+      }
+    }
+  }, {
+    ATTRS: {}
+  });
+  models.CharmList = CharmList;
+
+}, '0.1.0', {
+  requires: [
+    'model',
+    'model-list'
+  ]
+});

=== modified file 'app/models/models.js'
--- app/models/models.js	2012-10-15 13:26:16 +0000
+++ app/models/models.js	2012-10-19 02:11:22 +0000
@@ -45,188 +45,6 @@
     }
   };
 
-  // This is how the charm_id_re regex works for various inputs.  The first
-  // element is always the initial string, which we have elided in the
-  // examples.
-  // 'cs:~marcoceppi/precise/word-press-17' ->
-  // [..."cs", "marcoceppi", "precise", "word-press", "17"]
-  // 'cs:~marcoceppi/precise/word-press' ->
-  // [..."cs", "marcoceppi", "precise", "word-press", undefined]
-  // 'cs:precise/word-press' ->
-  // [..."cs", undefined, "precise", "word-press", undefined]
-  // 'cs:precise/word-press-17'
-  // [..."cs", undefined, "precise", "word-press", "17"]
-  var charm_id_re = /^(?:(\w+):)?(?:~(\S+)\/)?(\w+)\/(\S+?)(?:-(\d+))?$/,
-      parse_charm_id = function(id) {
-        var parts = charm_id_re.exec(id),
-            result = {};
-        if (parts) {
-          parts.shift();
-          Y.each(
-              Y.Array.zip(
-                  ['scheme', 'owner', 'series', 'package_name', 'revision'],
-                  parts),
-              function(pair) { result[pair[0]] = pair[1]; });
-          if (!Y.Lang.isValue(result.scheme)) {
-            result.scheme = 'cs'; // This is the default.
-          }
-          return result;
-        }
-        // return undefined;
-      },
-      _calculate_full_charm_name = function(elements) {
-        var tmp = [elements.series, elements.package_name];
-        if (elements.owner) {
-          tmp.unshift('~' + elements.owner);
-        }
-        return tmp.join('/');
-      };
-  // This is exposed for testing purposes.
-  models.parse_charm_id = parse_charm_id;
-
-  var Charm = Y.Base.create('charm', Y.Model, [], {
-    initializer: function() {
-      this.loaded = false;
-      this.on('load', function() { this.loaded = true; });
-    },
-    sync: function(action, options, callback) {
-      if (action !== 'read') {
-        throw (
-            'Only use the "read" action; "' + action + '" not supported.');
-      }
-      if (!Y.Lang.isValue(options.env) ||
-          !Y.Lang.isValue(options.charm_store)) {
-        throw 'You must supply both the env and the charm_store as options.';
-      }
-      var scheme = this.get('scheme'),
-          charm_id = this.get('id'),
-          revision = this.get('revision'),
-          self = this; // cheap, fast bind.
-      // Now, is the id local: or cs: ?
-      if (scheme === 'local') {
-        // Warning!  We don't have experience with local charms yet.
-        // This will likely need tweaking.
-        if (Y.Lang.isValue(revision)) {
-          // Local charms can honor the revision.
-          charm_id += '-' + revision;
-        }
-        options.env.get_charm(charm_id, function(ev) {
-          if (ev.err) {
-            console.log('error loading local charm', self, ev);
-            callback(true, ev);
-          } else {
-            callback(false, ev.result); // Result is already parsed.
-          }
-        });
-      } else if (scheme === 'cs') {
-        // Convert id to charmstore path.
-        options.charm_store.sendRequest({
-          request: this.get('charm_store_path'),
-          callback: {
-            'success': function(io_request) {
-              callback(false, io_request.response.results[0].responseText);
-            },
-            'failure': function(ev) {
-              console.log('error loading cs charm', self, ev);
-              callback(true, ev);
-            }
-          }
-        });
-      } else {
-        console.error('unknown charm id scheme: ' + scheme, this);
-      }
-    },
-    parse: function() {
-      var result = Charm.superclass.parse.apply(this, arguments);
-      result.is_subordinate = result.subordinate;
-      Y.each(
-          ['subordinate', 'name', 'revision'],
-          function(nm) { delete result[nm]; });
-      return result;
-    }
-  }, {
-    ATTRS: {
-      id: {
-        lazyAdd: false,
-        setter: function(val) {
-          var parts = parse_charm_id(val),
-              self = this;
-          if (!parts) {
-            return null;
-          }
-          Y.each(parts, function(value, key) {
-            self._set(key, value);
-          });
-          this._set(
-              'charm_store_path',
-              [(parts.owner ? '~' + parts.owner : 'charms'),
-               parts.series, parts.package_name, 'json']
-            .join('/'));
-          this._set('full_name', _calculate_full_charm_name(parts));
-          return parts.scheme + ':' + this.get('full_name');
-        },
-        validator: function(val) {
-          return Y.Lang.isValue(charm_id_re.exec(val));
-        }
-      },
-      // All of the below are loaded except as noted.
-      bzr_branch: {writeOnce: true},
-      charm_store_path: {readOnly: true}, // calculated
-      config: {writeOnce: true},
-      description: {writeOnce: true},
-      full_name: {readOnly: true}, // calculated
-      is_subordinate: {writeOnce: true},
-      last_change:
-          { writeOnce: true,
-            setter: function(val) {
-              if (val && val.created) {
-                // Mutating in place should be fine since this should only
-                // come from loading over the wire.
-                val.created = new Date(val.created * 1000);
-              }
-              return val;
-            }
-          },
-      maintainer: {writeOnce: true},
-      metadata: {writeOnce: true},
-      package_name: {readOnly: true}, // calculated
-      owner: {readOnly: true}, // calculated
-      peers: {writeOnce: true},
-      proof: {writeOnce: true},
-      provides: {writeOnce: true},
-      requires: {writeOnce: true},
-      revision: {readOnly: true}, // calculated
-      scheme: {readOnly: true}, // calculated
-      series: {readOnly: true}, // calculated
-      store_revision: {writeOnce: true},
-      summary: {writeOnce: true},
-      url: {writeOnce: true}
-    }
-  });
-  models.Charm = Charm;
-
-  var CharmList = Y.Base.create('charmList', Y.ModelList, [], {
-    model: Charm,
-    getById: function(id) {
-      // Normalize ids to not have revision numbers.
-      // Eventually it would be nice to be able to use revision numbers,
-      // but the charm store can't handle it, so we stick with the lowest
-      // common denominator: ids without revision numbers.
-      return CharmList.superclass.getById.apply(
-          this, [this.normalizeCharmId(id)]);
-    },
-    normalizeCharmId: function(id) {
-      var match = parse_charm_id(id);
-      if (match) {
-        return match.scheme + ':' + _calculate_full_charm_name(match);
-      }
-      return undefined;
-    }
-  }, {
-    ATTRS: {}
-  });
-  models.CharmList = CharmList;
-
   var Service = Y.Base.create('service', Y.Model, [], {
     ATTRS: {
       name: {},
@@ -564,7 +382,7 @@
   var Database = Y.Base.create('database', Y.Base, [], {
     initializer: function() {
       this.services = new ServiceList();
-      this.charms = new CharmList();
+      this.charms = new models.CharmList();
       this.relations = new RelationList();
       this.notifications = new NotificationList();
 
@@ -580,13 +398,13 @@
       this.machines = new MachineList();
 
       // For model syncing by type. Charms aren't currently sync'd, only
-      // fetched on demand (their static).
+      // fetched on demand (they're static).
       this.model_map = {
         'unit': ServiceUnit,
         'machine': Machine,
         'service': Service,
         'relation': Relation,
-        'charm': Charm
+        'charm': models.Charm
       };
     },
 
@@ -667,6 +485,7 @@
     'datasource-jsonschema',
     'io-base',
     'json-parse',
-    'juju-view-utils'
+    'juju-view-utils',
+    'juju-charm-models'
   ]
 });

=== modified file 'app/modules.js'
--- app/modules.js	2012-10-14 00:38:05 +0000
+++ app/modules.js	2012-10-19 02:11:22 +0000
@@ -82,8 +82,13 @@
           fullpath: '/juju-ui/models/endpoints.js'
         },
 
+        'juju-charm-models': {
+          fullpath: '/juju-ui/models/charm.js'
+        },
+
         'juju-models': {
-          requires: ['model', 'model-list', 'juju-endpoints'],
+          requires: [
+            'model', 'model-list', 'juju-endpoints', 'juju-charm-models'],
           fullpath: '/juju-ui/models/models.js'
         },
 

=== modified file 'app/store/charm.js'
--- app/store/charm.js	2012-09-26 23:31:13 +0000
+++ app/store/charm.js	2012-10-19 02:11:22 +0000
@@ -2,15 +2,128 @@
 
 YUI.add('juju-charm-store', function(Y) {
 
-
-  //
+  var CharmStore = Y.Base.create('charm', Y.Base, [], {
+
+    loadByPath: function(path, options) {
+      this.get('datasource').sendRequest({
+        request: path,
+        callback: {
+          success: function(io_request) {
+            options.success(
+                Y.JSON.parse(io_request.response.results[0].responseText));
+          },
+          failure: options.failure
+        }
+      });
+    },
+    // The query can be a string that is passed directly to the search url, or a
+    // hash that is marshalled to the correct format (e.g., {series:precise,
+    // owner:charmers}).
+    find: function(query, options) {
+      if (!Y.Lang.isString(query)) {
+        var tmp = [];
+        Y.each(query, function(val, key) {
+          tmp.push(key + ':' + val);
+        });
+        query = escape(tmp.join(' '));
+      }
+      this.get('datasource').sendRequest({
+        request: 'search/json?search_text=' + query,
+        callback: {
+          'success': Y.bind(function(io_request) {
+            // To see an example of what is being obtained, look at
+            // http://jujucharms.com/search/json?search_text=mysql .
+            var result_set = Y.JSON.parse(
+                io_request.response.results[0].responseText);
+            console.log('results update', result_set);
+            options.success(
+                this._normalizeCharms(
+                result_set.results, options.defaultSeries));
+          }, this),
+          'failure': options.failure
+        }});
+    },
+    // Stash the base id on each charm, convert the official "charmers" owner to
+    // an empty owner, and group the charms within series.  The series are
+    // arranged with first the defaultSeries, if any, and then all other
+    // available series arranged from newest to oldest. Within each series,
+    // official charms come first, sorted by relevance if available and package
+    // name otherwise; and then owned charms follow, sorted again by relevance
+    // if available and package name otherwise.
+    _normalizeCharms: function(charms, defaultSeries) {
+      var hash = {};
+      Y.each(charms, function(charm) {
+        charm.baseId = charm.series + '/' + charm.name;
+        if (charm.owner === 'charmers') {
+          charm.owner = null;
+        } else {
+          charm.baseId = '~' + charm.owner + '/' + charm.baseId;
+        }
+        charm.baseId = 'cs:' + charm.baseId;
+        if (!Y.Lang.isValue(hash[charm.series])) {
+          hash[charm.series] = [];
+        }
+        hash[charm.series].push(charm);
+      });
+      var series_names = Y.Object.keys(hash);
+      series_names.sort(function(a, b) {
+        if (a === defaultSeries && b !== defaultSeries) {
+          return -1;
+        } else if (a !== defaultSeries && b === defaultSeries) {
+          return 1;
+        } else if (a > b) {
+          return -1;
+        } else if (a < b) {
+          return 1;
+        } else {
+          return 0;
+        }
+      });
+      return Y.Array.map(series_names, function(name) {
+        var charms = hash[name];
+        charms.sort(function(a, b) {
+          // If !a.owner, that means it is owned by charmers.
+          if (!a.owner && b.owner) {
+            return -1;
+          } else if (a.owner && !b.owner) {
+            return 1;
+          } else if (a.relevance < b.relevance) {
+            return 1; // Higher relevance comes first.
+          } else if (a.relevance > b.relevance) {
+            return -1;
+          } else if (a.name < b.name) {
+            return -1;
+          } else if (a.name > b.name) {
+            return 1;
+          } else if (a.owner < b.owner) {
+            return -1;
+          } else if (a.owner > b.owner) {
+            return 1;
+          } else {
+            return 0;
+          }
+        });
+        return {series: name, charms: hash[name]};
+      });
+    }
+
+  }, {
+    ATTRS: {
+      datasource: {
+        setter: function(val) {
+          if (Y.Lang.isString(val)) {
+            val = new Y.DataSource.IO({ source: val });
+          }
+          return val;
+        }
+      }
+    }
+  });
+  Y.namespace('juju').CharmStore = CharmStore;
 
 }, '0.1.0', {
   requires: [
-    'io',
     'datasource-io',
-    'datasource-jsonschema',
-    'datasource-cache'
-    //      'json-parse',
+    'json-parse'
   ]
 });

=== modified file 'app/templates/charm-search-result.handlebars'
--- app/templates/charm-search-result.handlebars	2012-10-12 23:52:48 +0000
+++ app/templates/charm-search-result.handlebars	2012-10-19 02:11:22 +0000
@@ -9,10 +9,8 @@
           <div>
 
             <button class="btn btn-primary deploy"
-             data-url="{{url}}"
-             data-name="{{name}}"
-             data-info-url="{{data_url}}">Deploy</button>
-            <a class="charm-detail" href="{{url}}">
+             data-url="{{baseId}}">Deploy</button>
+            <a class="charm-detail" href="{{baseId}}">
               {{#if owner}}{{owner}}/{{/if}}{{name}}</a>
 
             <div class="charm-summary">{{summary}}</div>

=== modified file 'app/views/charm-search.js'
--- app/views/charm-search.js	2012-10-18 00:27:33 +0000
+++ app/views/charm-search.js	2012-10-19 02:11:22 +0000
@@ -44,18 +44,27 @@
       this.after('searchTextChange', function(ev) {
         this.set('resultEntries', null);
         if (ev.newVal) {
-          this.findCharms(ev.newVal, function(charms) {
-            self.set('resultEntries', charms);
-          });
+          this.get('charmStore').find(
+              ev.newVal,
+              { success: function(charms) {
+                self.set('resultEntries', charms);
+              },
+              failure: Y.bind(this._showErrors, this),
+              defaultSeries: this.get('defaultSeries')
+              });
         }
       });
       this.after('defaultSeriesChange', function(ev) {
         this.set('defaultEntries', null);
         if (ev.newVal) {
-          var searchString = 'series%3A' + ev.newVal + '+owner%3Acharmers';
-          this.findCharms(searchString, function(charms) {
-            self.set('defaultEntries', charms);
-          });
+          this.get('charmStore').find(
+              {series: ev.newVal, owner: 'charmers'},
+              { success: function(charms) {
+                self.set('defaultEntries', charms);
+              },
+              failure: Y.bind(this._showErrors, this),
+              defaultSeries: this.get('defaultSeries')
+              });
         }
       });
       this.after('defaultEntriesChange', function() {
@@ -92,77 +101,15 @@
           { name: 'configuration',
             charmId: ev.currentTarget.getData('url')});
     },
-    // Create a data structure friendly to the view
-    normalizeCharms: function(charms) {
-      var hash = {},
-          defaultSeries = this.get('defaultSeries');
-      Y.each(charms, function(charm) {
-        charm.url = charm.series + '/' + charm.name;
-        if (charm.owner === 'charmers') {
-          charm.owner = null;
-        } else {
-          charm.url = '~' + charm.owner + '/' + charm.url;
-        }
-        charm.url = 'cs:' + charm.url;
-        if (!Y.Lang.isValue(hash[charm.series])) {
-          hash[charm.series] = [];
-        }
-        hash[charm.series].push(charm);
-      });
-      var series_names = Y.Object.keys(hash);
-      series_names.sort(function(a, b) {
-        if ((a === defaultSeries && b !== defaultSeries) || a > b) {
-          return -1;
-        } else if ((a !== defaultSeries && b === defaultSeries) || a < b) {
-          return 1;
-        } else {
-          return 0;
-        }
-      });
-      return Y.Array.map(series_names, function(name) {
-        var charms = hash[name];
-        charms.sort(function(a, b) {
-          // If !a.owner, that means it is owned by charmers.
-          if ((!a.owner && b.owner) || (a.owner < b.owner)) {
-            return -1;
-          } else if ((a.owner && !b.owner) || (a.owner > b.owner)) {
-            return 1;
-          } else if (a.name < b.name) {
-            return -1;
-          } else if (a.name > b.name) {
-            return 1;
-          } else {
-            return 0;
-          }
-        });
-        return {series: name, charms: hash[name]};
-      });
-    },
-    findCharms: function(query, callback) {
-      var charmStore = this.get('charmStore'),
-          app = this.get('app');
-      charmStore.sendRequest({
-        request: 'search/json?search_text=' + query,
-        callback: {
-          'success': Y.bind(function(io_request) {
-            // To see an example of what is being obtained, look at
-            // http://jujucharms.com/search/json?search_text=mysql .
-            var result_set = Y.JSON.parse(
-                io_request.response.results[0].responseText);
-            console.log('results update', result_set);
-            callback(this.normalizeCharms(result_set.results));
-          }, this),
-          'failure': function er(e) {
-            console.error(e.error);
-            app.db.notifications.add(
-                new models.Notification({
-                  title: 'Could not retrieve charms',
-                  message: e.error,
-                  level: 'error'
-                })
-            );
-          }
-        }});
+    _showErrors: function(e) {
+      console.error(e.error);
+      this.get('app').db.notifications.add(
+          new models.Notification({
+            title: 'Could not retrieve charms',
+            message: e.error,
+            level: 'error'
+          })
+      );
     }
   });
   views.CharmCollectionView = CharmCollectionView;
@@ -441,6 +388,7 @@
   function createInstance(config) {
 
     var charmStore = config.charm_store,
+        charms = new models.CharmList(),
         app = config.app,
         testing = !!config.testing,
         container = Y.Node.create(views.Templates['charm-search-pop']({
@@ -483,12 +431,16 @@
         contentNode.get('children').remove();
         contentNode.append(panels[config.name].get('container'));
         if (config.charmId) {
-          var newModel = app.db.charms.getById(config.charmId);
-          if (!newModel) {
-            newModel = app.db.charms.add({id: config.charmId})
-              .load({env: app.env, charm_store: app.charm_store});
-          }
-          newPanel.set('model', newModel);
+          newPanel.set('model', null); // Clear out the old.
+          charms.loadOneByBaseId(
+              config.charmId,
+              { success: function(charm) {newPanel.set('model', charm);},
+                failure: function(data) {
+                  console.log('error loading charm', data);
+                  newPanel.fire('changePanel', {name: 'charms'});
+                },
+                charm_store: charmStore
+              });
         } else { // This is the search panel.
           newPanel.render();
         }
@@ -640,6 +592,7 @@
     'widget-anim',
     'overlay',
     'svg-layouts',
-    'dom-core'
+    'dom-core',
+    'juju-models'
   ]
 });

=== modified file 'app/views/charm.js'
--- app/views/charm.js	2012-10-10 13:25:57 +0000
+++ app/views/charm.js	2012-10-19 02:11:22 +0000
@@ -1,5 +1,10 @@
 'use strict';
 
+// Both of these views should probably either be deleted or converted to only
+// show environment charms.  My (GP) favorite option is to convert CharmView
+// into a service tab (viewing the environment's charm) and delete
+// CharmCollectionView.
+
 YUI.add('juju-view-charm-collection', function(Y) {
 
   var views = Y.namespace('juju.views'),
@@ -11,7 +16,7 @@
     initializer: function() {
       this.set('charm', null);
       console.log('Loading charm view', this.get('charm_data_url'));
-      this.get('charm_store').sendRequest({
+      this.get('charm_store').get('datasource').sendRequest({
         request: this.get('charm_data_url'),
         callback: {
           'success': Y.bind(this.on_charm_data, this),
@@ -149,7 +154,7 @@
       // The handling in datasources-plugins is an example of doing this a bit
       // better ie. io cancellation outstanding requests, it does seem to
       // cause some interference with various datasource plugins though.
-      this.get('charm_store').sendRequest({
+      this.get('charm_store').get('datasource').sendRequest({
         request: 'search/json?search_text=' + query,
         callback: {
           'success': Y.bind(this.on_results_change, this),

=== added file 'test/data/search_results.json'
--- test/data/search_results.json	1970-01-01 00:00:00 +0000
+++ test/data/search_results.json	2012-10-19 02:11:22 +0000
@@ -0,0 +1,64 @@
+{
+  "matches": 7, 
+  "charm_total": 466, 
+  "results_size": 7, 
+  "search_time": 0.0011610984802246094, 
+  "results": [
+    {
+      "data_url": "/charms/precise/cassandra/json", 
+      "name": "cassandra", 
+      "series": "precise", 
+      "summary": "distributed storage system for structured data", 
+      "relevance": 29.552706339212623, 
+      "owner": "charmers"
+    }, 
+    {
+      "data_url": "/charms/oneiric/cassandra/json", 
+      "name": "cassandra", 
+      "series": "oneiric", 
+      "summary": "distributed storage system for structured data", 
+      "relevance": 29.429741180715094, 
+      "owner": "charmers"
+    }, 
+    {
+      "data_url": "/~jjo/precise/cassandra/json", 
+      "name": "cassandra", 
+      "series": "precise", 
+      "summary": "distributed storage system for structured data", 
+      "relevance": 28.060048153642214, 
+      "owner": "jjo"
+    }, 
+    {
+      "data_url": "/~ev/precise/errors/json", 
+      "name": "errors", 
+      "series": "precise", 
+      "summary": "https://errors.ubuntu.com";, 
+      "relevance": 13.168754948011127, 
+      "owner": "ev"
+    }, 
+    {
+      "data_url": "/~ev/precise/daisy/json", 
+      "name": "daisy", 
+      "series": "precise", 
+      "summary": "Daisy error reporting server", 
+      "relevance": 12.721871518319244, 
+      "owner": "ev"
+    }, 
+    {
+      "data_url": "/~ev/precise/daisy-retracer/json", 
+      "name": "daisy-retracer", 
+      "series": "precise", 
+      "summary": "Daisy error reporting server retracer", 
+      "relevance": 12.662102672474589, 
+      "owner": "ev"
+    }, 
+    {
+      "data_url": "/~negronjl/oneiric/cassandra/json", 
+      "name": "cassandra", 
+      "series": "oneiric", 
+      "summary": "distributed storage system for structured data", 
+      "relevance": 30.40622159052724, 
+      "owner": "negronjl"
+    }
+  ]
+}
\ No newline at end of file

=== added file 'test/data/series_search_results.json'
--- test/data/series_search_results.json	1970-01-01 00:00:00 +0000
+++ test/data/series_search_results.json	2012-10-19 02:11:22 +0000
@@ -0,0 +1,48 @@
+{
+  "matches": 5, 
+  "charm_total": 466, 
+  "results_size": 5, 
+  "search_time": 0.0008029937744140625, 
+  "results": [
+    {
+      "data_url": "/charms/quantal/glance/json", 
+      "name": "glance", 
+      "series": "quantal", 
+      "summary": "OpenStack Image Registry and Delivery Service", 
+      "relevance": 0.0, 
+      "owner": "charmers"
+    }, 
+    {
+      "data_url": "/charms/quantal/nova-cloud-controller/json", 
+      "name": "nova-cloud-controller", 
+      "series": "quantal", 
+      "summary": "Openstack nova controller node.", 
+      "relevance": 0.0, 
+      "owner": "charmers"
+    }, 
+    {
+      "data_url": "/charms/quantal/nova-volume/json", 
+      "name": "nova-volume", 
+      "series": "quantal", 
+      "summary": "OpenStack Compute - storage", 
+      "relevance": 0.0, 
+      "owner": "charmers"
+    }, 
+    {
+      "data_url": "/charms/quantal/nova-compute/json", 
+      "name": "nova-compute", 
+      "series": "quantal", 
+      "summary": "OpenStack compute", 
+      "relevance": 0.0, 
+      "owner": "charmers"
+    }, 
+    {
+      "data_url": "/charms/quantal/nyancat/json", 
+      "name": "nyancat", 
+      "series": "quantal", 
+      "summary": "Nyancat telnet server", 
+      "relevance": 0.0, 
+      "owner": "charmers"
+    }
+  ]
+}
\ No newline at end of file

=== modified file 'test/index.html'
--- test/index.html	2012-10-14 00:38:05 +0000
+++ test/index.html	2012-10-19 02:11:22 +0000
@@ -31,6 +31,7 @@
   <script src="test_console.js"></script>
   <script src="test_endpoints.js"></script>
   <script src="test_application_notifications.js"></script>
+  <script src="test_charm_store.js"></script>
 
   <script>
   YUI().use('node', 'event', function(Y) {

=== modified file 'test/test_app.js'
--- test/test_app.js	2012-10-11 19:42:36 +0000
+++ test/test_app.js	2012-10-19 02:11:22 +0000
@@ -176,7 +176,7 @@
 });
 
 describe('Application prefetching', function() {
-  var Y, models, conn, env, app, container, charm_store, data;
+  var Y, models, conn, env, app, container, charm_store, data, juju;
 
   before(function(done) {
     Y = YUI(GlobalConfig).use(
@@ -190,17 +190,16 @@
 
   beforeEach(function() {
     conn = new (Y.namespace('juju-tests.utils')).SocketStub(),
-    env = new (Y.namespace('juju')).Environment({conn: conn});
+    env = new Y.juju.Environment({conn: conn});
     env.connect();
     conn.open();
     container = Y.Node.create('<div id="test" class="container"></div>');
     data = [];
-    charm_store = new Y.DataSource.Local({source: data});
     app = new Y.juju.App(
         { container: container,
           viewContainer: container,
           env: env,
-          charm_store: charm_store });
+          charm_store: {} });
 
     app.updateEndpoints = function() {};
     env.get_endpoints = function() {};
@@ -213,14 +212,15 @@
 
   it('must prefetch charm and service for service pages', function() {
     injectData(app);
-    data.push({responseText: Y.JSON.stringify({summary: 'wowza'})});
     var _ = expect(
-        app.db.charms.getById('cs:precise/wordpress')).to.not.exist;
+        app.db.charms.getById('cs:precise/wordpress-6')).to.not.exist;
     app.show_service({params: {id: 'wordpress'}, query: {}});
-    // The db loaded the charm information from the datastore.
-    app.db.charms.getById(
-        'cs:precise/wordpress').get('summary').should.equal('wowza');
     // The app made a request of juju for the service info.
-    conn.last_message().op.should.equal('get_service');
+    conn.messages[conn.messages.length - 2].op.should.equal('get_service');
+    // The app also requested juju (not the charm store--see discussion in
+    // app/models/charm.js) for the charm info.
+    conn.last_message().op.should.equal('get_charm');
+    // Tests of the actual load machinery are in the model and env tests, and
+    // so are not repeated here.
   });
 });

=== modified file 'test/test_charm_configuration.js'
--- test/test_charm_configuration.js	2012-10-18 00:27:33 +0000
+++ test/test_charm_configuration.js	2012-10-19 02:11:22 +0000
@@ -57,7 +57,7 @@
   });
 
   it('must have inputs for service and number of units', function() {
-    var charm = new models.Charm({id: 'precise/mysql'}),
+    var charm = new models.Charm({id: 'precise/mysql-7'}),
         view = new views.CharmConfigurationView(
         { container: container,
           model: charm});
@@ -70,7 +70,7 @@
   });
 
   it('must have inputs for items in the charm schema', function() {
-    var charm = new models.Charm({id: 'precise/mysql'}),
+    var charm = new models.Charm({id: 'precise/mysql-7'}),
         view = new views.CharmConfigurationView(
         { container: container,
           model: charm});
@@ -91,7 +91,7 @@
           received_charm_url = charm_url;
           received_service_name = service_name;
         }},
-        charm = new models.Charm({id: 'precise/mysql'}),
+        charm = new models.Charm({id: 'precise/mysql-7'}),
         view = new views.CharmConfigurationView(
         { container: container,
           model: charm,
@@ -102,7 +102,7 @@
     container.one('#service-name').get('value').should.equal('mysql');
     container.one('#charm-deploy').simulate('click');
     received_service_name.should.equal('mysql');
-    received_charm_url.should.equal('cs:precise/mysql');
+    received_charm_url.should.equal('cs:precise/mysql-7');
   });
 
   it('must deploy a charm with the custom configuration', function() {
@@ -119,7 +119,7 @@
             received_config = config;
             received_num_units = num_units;
           }},
-        charm = new models.Charm({id: 'precise/mysql'}),
+        charm = new models.Charm({id: 'precise/mysql-7'}),
         view = new views.CharmConfigurationView(
         { container: container,
           model: charm,
@@ -141,7 +141,7 @@
     container.one('#input-option0').set('value', 'cows');
     container.one('#charm-deploy').simulate('click');
     received_service_name.should.equal('aaa');
-    received_charm_url.should.equal('cs:precise/mysql');
+    received_charm_url.should.equal('cs:precise/mysql-7');
     received_num_units.should.equal(24);
     received_config.should.eql({option0: 'cows'});
   });
@@ -153,7 +153,7 @@
                                num_units) {
            deployed = true;
          }},
-       charm = new models.Charm({id: 'precise/mysql'}),
+       charm = new models.Charm({id: 'precise/mysql-7'}),
        view = new views.CharmConfigurationView(
        { container: container,
          model: charm,
@@ -173,7 +173,7 @@
      });
 
   it('must show the description in a tooltip', function() {
-    var charm = new models.Charm({id: 'precise/mysql'}),
+    var charm = new models.Charm({id: 'precise/mysql-7'}),
         view = new views.CharmConfigurationView(
         { container: container,
           model: charm,
@@ -206,7 +206,7 @@
   });
 
   it('must keep the tooltip aligned with its field', function() {
-    var charm = new models.Charm({id: 'precise/mysql'}),
+    var charm = new models.Charm({id: 'precise/mysql-7'}),
         view = new views.CharmConfigurationView(
         { container: container,
           model: charm,
@@ -236,7 +236,7 @@
   });
 
   it('must hide the tooltip when its field scrolls away', function() {
-    var charm = new models.Charm({id: 'precise/mysql'}),
+    var charm = new models.Charm({id: 'precise/mysql-7'}),
         view = new views.CharmConfigurationView(
         { container: container,
           model: charm,
@@ -263,7 +263,7 @@
 
   it('must not show a configuration file upload button if the charm ' +
       'has no settings', function() {
-       var charm = new models.Charm({id: 'precise/mysql'}),
+       var charm = new models.Charm({id: 'precise/mysql-7'}),
        view = new views.CharmConfigurationView(
        { container: container,
          model: charm,
@@ -275,7 +275,7 @@
 
   it('must show a configuration file upload button if the charm ' +
       'has settings', function() {
-        var charm = new models.Charm({id: 'precise/mysql'});
+        var charm = new models.Charm({id: 'precise/mysql-7'});
         charm.setAttrs(
            { config:
              { options:
@@ -299,7 +299,7 @@
      });
 
   it('must hide configuration panel when a file is uploaded', function() {
-    var charm = new models.Charm({id: 'precise/mysql'});
+    var charm = new models.Charm({id: 'precise/mysql-7'});
     charm.setAttrs(
         { config:
               { options:
@@ -323,7 +323,7 @@
   });
 
   it('must remove configuration data when the button is pressed', function() {
-    var charm = new models.Charm({id: 'precise/mysql'});
+    var charm = new models.Charm({id: 'precise/mysql-7'});
     charm.setAttrs(
         { config:
               { options:
@@ -350,7 +350,7 @@
   it('must be able to deploy with configuration from a file', function() {
     var received_config,
         received_config_raw,
-        charm = new models.Charm({id: 'precise/mysql'}),
+        charm = new models.Charm({id: 'precise/mysql-7'}),
         app = {db: {services: {getById: function(name) {return null;}}},
           env: {deploy: function(charm_url, service_name, config, config_raw,
                                  num_units) {

=== modified file 'test/test_charm_search.js'
--- test/test_charm_search.js	2012-10-12 23:52:48 +0000
+++ test/test_charm_search.js	2012-10-19 02:11:22 +0000
@@ -1,7 +1,7 @@
 'use strict';
 
 describe('charm search', function() {
-  var Y, models, views, ENTER, ESC,
+  var Y, models, views, juju, ENTER,
       searchResult = '{"results": [{"data_url": "this is my URL", ' +
       '"name": "membase", "series": "precise", "summary": ' +
       '"Membase Server", "relevance": 8.728194117350437, ' +
@@ -17,10 +17,12 @@
         'node-event-simulate',
         'node',
         'event-key',
+        'juju-charm-store',
 
         function(Y) {
           models = Y.namespace('juju.models');
           views = Y.namespace('juju.views');
+          juju = Y.namespace('juju');
           ENTER = Y.Node.DOM_EVENTS.key.eventDef.KEY_MAP.enter;
           done();
         });
@@ -60,7 +62,7 @@
   it('must be able to search', function() {
     var searchTriggered = false,
         panel = Y.namespace('juju.views').CharmSearchPopup.getInstance({
-          charm_store: {
+          charm_store: new juju.CharmStore({datasource: {
             sendRequest: function(params) {
               searchTriggered = true;
               // Mocking the server callback value
@@ -72,7 +74,7 @@
                 }
               });
             }
-          },
+          }}),
           testing: true
         }),
         node = panel.node;
@@ -82,14 +84,14 @@
     field.simulate('keydown', { keyCode: ENTER });
 
     searchTriggered.should.equal(true);
-    node.one('.charm-entry .btn.deploy').getData('info-url').should.equal(
-        'this is my URL');
+    node.one('.charm-entry .btn.deploy').getData('url').should.equal(
+        'cs:precise/membase');
   });
 
   it('must be able to trigger charm details', function() {
     var db = new models.Database(),
         panel = Y.namespace('juju.views').CharmSearchPopup.getInstance({
-          charm_store: {
+          charm_store: new juju.CharmStore({datasource: {
             sendRequest: function(params) {
               // Mocking the server callback value
               params.callback.success({
@@ -100,7 +102,7 @@
                 }
               });
             }
-          },
+          }}),
           app: {db: db},
           testing: true
         }),
@@ -120,7 +122,7 @@
      'configuration panel', function() {
         var db = new models.Database(),
             panel = Y.namespace('juju.views').CharmSearchPopup.getInstance({
-              charm_store: {
+              charm_store: new juju.CharmStore({datasource: {
                 sendRequest: function(params) {
                   // Mocking the server callback value
                   params.callback.success({
@@ -131,7 +133,7 @@
                     }
                   });
                 }
-              },
+              }}),
               app: {db: db},
               testing: true
             }),
@@ -150,7 +152,7 @@
 });
 
 describe('charm description', function() {
-  var Y, models, views, conn, env, container, db, app, charm,
+  var Y, models, views, juju, conn, env, container, db, app, charm,
       charm_store_data, charm_store;
 
   before(function(done) {
@@ -164,10 +166,12 @@
         'node',
         'datasource-local',
         'json-stringify',
+        'juju-charm-store',
 
         function(Y) {
           models = Y.namespace('juju.models');
           views = Y.namespace('juju.views');
+          juju = Y.namespace('juju');
           done();
         });
 
@@ -181,9 +185,10 @@
     container = Y.Node.create('<div id="test-container" />');
     Y.one('#main').append(container);
     db = new models.Database();
-    charm = db.charms.add({ id: 'cs:precise/mysql' });
+    charm = db.charms.add({ id: 'cs:precise/mysql-7' });
     charm_store_data = [];
-    charm_store = new Y.DataSource.Local({source: charm_store_data});
+    charm_store = new juju.CharmStore(
+        {datasource: new Y.DataSource.Local({source: charm_store_data})});
     app = { db: db, env: env, charm_store: charm_store };
   });
 

=== added file 'test/test_charm_store.js'
--- test/test_charm_store.js	1970-01-01 00:00:00 +0000
+++ test/test_charm_store.js	2012-10-19 02:11:22 +0000
@@ -0,0 +1,166 @@
+'use strict';
+
+(function() {
+
+  describe('juju charm store', function() {
+    var Y, models, conn, env, app, container, charm_store, data, juju;
+
+    before(function(done) {
+      Y = YUI(GlobalConfig).use(
+          'datasource-local', 'json-stringify', 'juju-charm-store',
+          'datasource-io', 'io', 'array-extras',
+          function(Y) {
+            juju = Y.namespace('juju');
+            done();
+          });
+    });
+
+    beforeEach(function() {
+      data = [];
+      charm_store = new juju.CharmStore(
+          {datasource: new Y.DataSource.Local({source: data})});
+    });
+
+    it('creates a remote datasource if you simply supply a uri', function() {
+      charm_store.set('datasource', 'http://example.com/');
+      var datasource = charm_store.get('datasource');
+      assert(datasource instanceof Y.DataSource.IO);
+      datasource.get('source').should.equal('http://example.com/');
+    });
+
+    it('handles loadByPath success correctly', function(done) {
+      data.push(
+          { responseText: Y.JSON.stringify(
+          { summary: 'wowza' })});
+      charm_store.loadByPath(
+          'whatever',
+          { success: function(data) {
+            data.summary.should.equal('wowza');
+            done();
+          },
+          failure: assert.fail
+          }
+      );
+    });
+
+    it('handles loadByPath failure correctly', function(done) {
+      // datasource._defRequestFn is designed to be overridden to achieve more
+      // complex behavior when a request is received.  We simply declare that
+      // an error occurred.
+      var datasource = charm_store.get('datasource'),
+          original = datasource._defResponseFn;
+      datasource._defResponseFn = function(e) {
+        e.error = true;
+        original.apply(datasource, [e]);
+      };
+      data.push({responseText: Y.JSON.stringify({darn_it: 'uh oh!'})});
+      charm_store.loadByPath(
+          'whatever',
+          { success: assert.fail,
+            failure: function(e) {
+              e.error.should.equal(true);
+              done();
+            }
+          }
+      );
+    });
+
+    it('sends a proper request for loadByPath', function() {
+      var args;
+      charm_store.set('datasource', {
+        sendRequest: function(params) {
+          args = params;
+        }
+      });
+      charm_store.loadByPath('/foo/bar', {});
+      args.request.should.equal('/foo/bar');
+    });
+
+    it('sends a proper request for a string call to find', function() {
+      var args;
+      charm_store.set('datasource', {
+        sendRequest: function(params) {
+          args = params;
+        }
+      });
+      charm_store.find('foobar', {});
+      args.request.should.equal('search/json?search_text=foobar');
+    });
+
+    it('sends a proper request for a hash call to find', function() {
+      var args;
+      charm_store.set('datasource', {
+        sendRequest: function(params) {
+          args = params;
+        }
+      });
+      charm_store.find({foo: 'bar', sha: 'zam'}, {});
+      args.request.should.equal(
+          'search/json?search_text=' + escape('foo:bar sha:zam'));
+    });
+
+    it('processes and orders search text requests properly', function(done) {
+      // This is data from
+      // http://jujucharms.com/search/json?search_text=cassandra .
+      data.push(Y.io('data/search_results.json', {sync: true}));
+      charm_store.find('cassandra',
+          { success: function(results) {
+            results.length.should.equal(2);
+            results[0].series.should.equal('precise');
+            Y.Array.map(results[0].charms, function(charm) {
+              return charm.owner;
+            }).should.eql([null, 'jjo', 'ev', 'ev', 'ev']);
+            Y.Array.map(results[0].charms, function(charm) {
+              return charm.baseId;
+            }).should.eql([
+              'cs:precise/cassandra',
+              'cs:~jjo/precise/cassandra',
+              'cs:~ev/precise/errors',
+              'cs:~ev/precise/daisy',
+              'cs:~ev/precise/daisy-retracer']);
+            done();
+          },
+          failure: assert.fail
+          });
+    });
+
+    it('honors defaultSeries in sorting search results', function(done) {
+      // This is data from
+      // http://jujucharms.com/search/json?search_text=cassandra .
+      data.push(Y.io('data/search_results.json', {sync: true}));
+      charm_store.find('cassandra',
+          { defaultSeries: 'oneiric',
+            success: function(results) {
+              results.length.should.equal(2);
+              results[0].series.should.equal('oneiric');
+              done();
+            },
+            failure: assert.fail
+          });
+    });
+
+    it('processes and orders search series requests properly', function(done) {
+      // This is data from
+      // http://jujucharms.com/search/json [CONTINUED ON NEXT LINE]
+      // ?search_text=series:quantal+owner:charmers .
+      data.push(Y.io('data/series_search_results.json', {sync: true}));
+      charm_store.find('cassandra',
+          { success: function(results) {
+            results.length.should.equal(1);
+            results[0].series.should.equal('quantal');
+            Y.Array.map(results[0].charms, function(charm) {
+              return charm.name;
+            }).should.eql([
+              'glance',
+              'nova-cloud-controller',
+              'nova-compute',
+              'nova-volume',
+              'nyancat']);
+            done();
+          },
+          failure: assert.fail
+          });
+    });
+
+  });
+})();

=== modified file 'test/test_charm_view.js'
--- test/test_charm_view.js	2012-10-04 15:04:26 +0000
+++ test/test_charm_view.js	2012-10-19 02:11:22 +0000
@@ -11,7 +11,7 @@
     before(function(done) {
       Y = YUI(GlobalConfig).use([
         'juju-views', 'juju-tests-utils', 'juju-env',
-        'node-event-simulate'
+        'node-event-simulate', 'juju-charm-store'
       ], function(Y) {
         testUtils = Y.namespace('juju-tests.utils');
         juju = Y.namespace('juju');
@@ -58,11 +58,14 @@
       Y.one('#main').append(container);
       CharmView = juju.views.charm;
       // Use a local charm store.
-      localCharmStore = new Y.DataSource.Local({
-        source: [{
-          responseText: Y.JSON.stringify(charmResults)
-        }]
-      });
+      localCharmStore = new juju.CharmStore(
+          { datasource: new Y.DataSource.Local({
+            source: [{
+              responseText: Y.JSON.stringify(charmResults)
+            }]
+          })
+          }
+          );
       conn = new testUtils.SocketStub();
       env = new juju.Environment({conn: conn});
       env.connect();
@@ -147,9 +150,13 @@
       // We appear to mutate a global here, but charmResults will be recreated
       // for the next test in beforeEach.
       delete charmResults.config;
-      var charmStore = new Y.DataSource.Local(
+      var charmStore = new juju.CharmStore(
+          { datasource: new Y.DataSource.Local(
           { source:
-                [{responseText: Y.JSON.stringify(charmResults)}]}),
+                [{responseText: Y.JSON.stringify(charmResults)}]
+          })
+          }
+          ),
           view = new CharmView(
           { charm_data_url: charmQuery,
             charm_store: charmStore,

=== modified file 'test/test_model.js'
--- test/test_model.js	2012-10-14 00:38:05 +0000
+++ test/test_model.js	2012-10-19 02:11:22 +0000
@@ -12,20 +12,6 @@
       });
     });
 
-    it('must normalize charm ids when creating', function() {
-      var charm = new models.Charm({id: 'precise/openstack-dashboard-0'});
-      charm.get('id').should.equal('cs:precise/openstack-dashboard');
-      // It also normalizes scheme value.
-      charm.get('scheme').should.equal('cs');
-    });
-
-    it('must normalize charm ids from getById', function() {
-      var charms = new models.CharmList(),
-          original = charms.add({id: 'cs:precise/openstack-dashboard'}),
-          charm = charms.getById('precise/openstack-dashboard-0');
-      charm.should.equal(original);
-    });
-
     it('must create derived attributes from official charm id', function() {
       var charm = new models.Charm(
           {id: 'cs:precise/openstack-dashboard-0'});
@@ -81,17 +67,6 @@
         .should.equal('alt-bac');
     });
 
-    it('must normalize a charm id without a scheme', function() {
-      new models.CharmList().normalizeCharmId('precise/openstack-dashboard')
-        .should.equal('cs:precise/openstack-dashboard');
-    });
-
-    it('must normalize a charm id with a revision', function() {
-      new models.CharmList()
-        .normalizeCharmId('local:precise/openstack-dashboard-5')
-        .should.equal('local:precise/openstack-dashboard');
-    });
-
   });
 
   describe('juju models', function() {
@@ -111,7 +86,7 @@
       charm.get('owner').should.equal('bac');
       charm.get('series').should.equal('precise');
       charm.get('package_name').should.equal('openstack-dashboard');
-      charm.get('revision').should.equal('0');
+      charm.get('revision').should.equal(0);
       charm.get('full_name').should.equal('~bac/precise/openstack-dashboard');
       charm.get('charm_store_path').should.equal(
           '~bac/precise/openstack-dashboard/json');
@@ -145,12 +120,13 @@
 
     it('must be able to create charm list', function() {
       var c1 = new models.Charm(
-          { id: 'cs:precise/mysql',
+          { id: 'cs:precise/mysql-2',
             description: 'A DB'}),
           c2 = new models.Charm(
-          { id: 'cs:precise/logger',
+          { id: 'cs:precise/logger-3',
             description: 'Log sub'}),
-          clist = new models.CharmList().add([c1, c2]);
+          clist = new models.CharmList();
+      clist.add([c1, c2]);
       var names = clist.map(function(c) {return c.get('package_name');});
       names[0].should.equal('mysql');
       names[1].should.equal('logger');
@@ -398,26 +374,28 @@
   });
 
   describe('juju charm load', function() {
-    var Y, models, conn, env, app, container, charm_store, data;
+    var Y, models, conn, env, app, container, charm_store, data, juju;
 
     before(function(done) {
       Y = YUI(GlobalConfig).use(
           'juju-models', 'juju-gui', 'datasource-local', 'juju-tests-utils',
-          'json-stringify',
+          'json-stringify', 'juju-charm-store',
           function(Y) {
             models = Y.namespace('juju.models');
+            juju = Y.namespace('juju');
             done();
           });
     });
 
     beforeEach(function() {
       conn = new (Y.namespace('juju-tests.utils')).SocketStub(),
-      env = new (Y.namespace('juju')).Environment({conn: conn});
+      env = new juju.Environment({conn: conn});
       env.connect();
       conn.open();
       container = Y.Node.create('<div id="test" class="container"></div>');
       data = [];
-      charm_store = new Y.DataSource.Local({source: data});
+      charm_store = new juju.CharmStore(
+          {datasource: new Y.DataSource.Local({source: data})});
     });
 
     afterEach(function() {
@@ -446,41 +424,40 @@
       }
     });
 
-    it('throws an error if you do not pass env and charm_store', function() {
+    it('throws an error if you do not pass a get_charm function', function() {
       var charm = new models.Charm({id: 'local:precise/foo'});
       try {
         charm.sync('read', {});
         assert.fail('Should have thrown an error');
       } catch (e) {
         e.should.equal(
-            'You must supply both the env and the charm_store as options.');
+            'You must supply a get_charm function.');
       }
       try {
         charm.sync('read', {env: 42});
         assert.fail('Should have thrown an error');
       } catch (e) {
         e.should.equal(
-            'You must supply both the env and the charm_store as options.');
+            'You must supply a get_charm function.');
       }
       try {
         charm.sync('read', {charm_store: 42});
         assert.fail('Should have thrown an error');
       } catch (e) {
         e.should.equal(
-            'You must supply both the env and the charm_store as options.');
+            'You must supply a get_charm function.');
       }
     });
 
     it('must send request to juju environment for local charms', function() {
-      var charm = new models.Charm({id: 'local:precise/foo'}).load(
-          {env: env, charm_store: charm_store});
+      var charm = new models.Charm({id: 'local:precise/foo'}).load(env);
       assert(!charm.loaded);
       conn.last_message().op.should.equal('get_charm');
     });
 
     it('must handle success from local charm request', function(done) {
       var charm = new models.Charm({id: 'local:precise/foo'}).load(
-          {env: env, charm_store: charm_store},
+          env,
           function(err, response) {
             assert(!err);
             charm.get('summary').should.equal('wowza');
@@ -495,7 +472,7 @@
 
     it('must handle failure from local charm request', function(done) {
       var charm = new models.Charm({id: 'local:precise/foo'}).load(
-          {env: env, charm_store: charm_store},
+          env,
           function(err, response) {
             assert(err);
             assert(response.err);
@@ -508,38 +485,52 @@
       // The test in the callback above should run.
     });
 
-    it('must handle success from the charm store', function() {
+    it('must handle success from the charm store', function(done) {
       data.push(
           { responseText: Y.JSON.stringify(
-          { summary: 'wowza', subordinate: true })});
-      var charm = new models.Charm({id: 'cs:precise/foo-7'}).load(
-          {env: env, charm_store: charm_store},
-          function(err, response) {
-            assert(!err);
+          { summary: 'wowza', subordinate: true, store_revision: 7 })});
+
+      var list = new models.CharmList();
+      list.loadOneByBaseId(
+          'cs:precise/foo',
+          { success: function(charm) {
+            assert(charm.loaded);
+            charm.get('summary').should.equal('wowza');
+            charm.get('is_subordinate').should.equal(true);
+            charm.get('scheme').should.equal('cs');
+            charm.get('revision').should.equal(7);
+            charm.get('id').should.equal('cs:precise/foo-7');
+            list.getById('cs:precise/foo-7').should.equal(charm);
+            list.getOneByBaseId('cs:precise/foo').should.equal(charm);
+            done();
+          },
+          failure: assert.fail,
+          charm_store: charm_store
           });
-      assert(charm.loaded);
-      charm.get('summary').should.equal('wowza');
-      charm.get('is_subordinate').should.equal(true);
-      charm.get('scheme').should.equal('cs');
-      charm.get('revision').should.equal('7');
     });
 
-    it('must handle failure from the charm store', function() {
-      // _defRequestFn is designed to be overridden to achieve more complex
-      // behavior when a request is received.  We simply declare that an
-      // error occurred.
-      var original = charm_store._defResponseFn;
-      charm_store._defResponseFn = function(e) {
+    it('must handle failure from the charm store', function(done) {
+      // datasource._defRequestFn is designed to be overridden to achieve more
+      // complex behavior when a request is received.  We simply declare that
+      // an error occurred.
+      var datasource = charm_store.get('datasource'),
+          original = datasource._defResponseFn,
+          list = new models.CharmList();
+      datasource._defResponseFn = function(e) {
         e.error = true;
-        original.apply(charm_store, [e]);
+        original.apply(datasource, [e]);
       };
       data.push({responseText: Y.JSON.stringify({darn_it: 'uh oh!'})});
-      var charm = new models.Charm({id: 'cs:precise/foo'}).load(
-          {env: env, charm_store: charm_store},
-          function(err, response) {
-            assert(err);
+      list.loadOneByBaseId(
+          'cs:precise/foo',
+          { success: assert.fail,
+            failure: function(err) {
+              var _ = expect(list.getOneByBaseId('cs:precise/foo'))
+                .to.not.exist;
+              done();
+            },
+            charm_store: charm_store
           });
-      assert(!charm.loaded);
     });
 
   });

=== modified file 'test/test_service_config_view.js'
--- test/test_service_config_view.js	2012-10-10 17:45:50 +0000
+++ test/test_service_config_view.js	2012-10-19 02:11:22 +0000
@@ -34,7 +34,7 @@
       Y.one('#main').append(container);
       db = new models.Database();
       charm = new models.Charm(
-          { id: 'cs:precise/mysql',
+          { id: 'cs:precise/mysql-7',
             description: 'A DB',
             config:
                 { options:
@@ -66,7 +66,7 @@
                 return model.get('name'); }};
       service = new models.Service({
         id: 'mysql',
-        charm: 'cs:precise/mysql',
+        charm: 'cs:precise/mysql-7',
         unit_count: db.units.size(),
         loaded: true,
         config: {

=== modified file 'test/test_unit_view.js'
--- test/test_unit_view.js	2012-10-05 21:53:27 +0000
+++ test/test_unit_view.js	2012-10-19 02:11:22 +0000
@@ -33,13 +33,13 @@
       Y.one('#main').append(container);
       db = new models.Database();
       charm = new models.Charm({
-        id: 'cs:precise/mysql',
+        id: 'cs:precise/mysql-5',
         name: 'mysql',
         description: 'A DB'});
       db.charms.add([charm]);
       service = new models.Service({
         id: 'mysql',
-        charm: 'cs:precise/mysql',
+        charm: 'cs:precise/mysql-5',
         unit_count: 1,
         loaded: true});
       db.relations.add({


Follow ups