OLD | NEW |
1 'use strict'; | 1 'use strict'; |
2 | 2 |
3 YUI.add('juju-charm-models', function(Y) { | 3 YUI.add('juju-charm-models', function(Y) { |
4 | 4 |
| 5 |
5 var models = Y.namespace('juju.models'); | 6 var models = Y.namespace('juju.models'); |
6 | 7 |
7 // This is how the charm_id_re regex works for various inputs. The first | 8 // Charms, once instantiated and loaded with data from their respective |
8 // element is always the initial string, which we have elided in the | 9 // sources, are immutable and read-only. This reflects the reality of how we |
9 // examples. | 10 // interact with them. |
10 // 'cs:~marcoceppi/precise/word-press-17' -> | |
11 // [..."cs", "marcoceppi", "precise", "word-press", "17"] | |
12 // 'cs:~marcoceppi/precise/word-press' -> | |
13 // [..."cs", "marcoceppi", "precise", "word-press", undefined] | |
14 // 'cs:precise/word-press' -> | |
15 // [..."cs", undefined, "precise", "word-press", undefined] | |
16 // 'cs:precise/word-press-17' | |
17 // [..."cs", undefined, "precise", "word-press", "17"] | |
18 var charm_id_re = /^(?:(\w+):)?(?:~(\S+)\/)?(\w+)\/(\S+?)(?:-(\d+))?$/, | |
19 parse_charm_id = function(id) { | |
20 var parts = charm_id_re.exec(id), | |
21 result = {}; | |
22 if (parts) { | |
23 parts.shift(); | |
24 Y.each( | |
25 Y.Array.zip( | |
26 ['scheme', 'owner', 'series', 'package_name', 'revision'], | |
27 parts), | |
28 function(pair) { result[pair[0]] = pair[1]; }); | |
29 if (!Y.Lang.isValue(result.scheme)) { | |
30 result.scheme = 'cs'; // This is the default. | |
31 } | |
32 return result; | |
33 } | |
34 // return undefined; | |
35 }, | |
36 _calculate_full_charm_name = function(elements) { | |
37 var tmp = [elements.series, elements.package_name]; | |
38 if (elements.owner) { | |
39 tmp.unshift('~' + elements.owner); | |
40 } | |
41 return tmp.join('/'); | |
42 }, | |
43 _calculate_charm_store_path = function(elements) { | |
44 return [(elements.owner ? '~' + elements.owner : 'charms'), | |
45 elements.series, elements.package_name, 'json'].join('/'); | |
46 }, | |
47 _calculate_base_charm_id = function(elements) { | |
48 return elements.scheme + ':' + _calculate_full_charm_name(elements); | |
49 }, | |
50 _reconsititute_charm_id = function(elements) { | |
51 return _calculate_base_charm_id(elements) + '-' + elements.revision; | |
52 }, | |
53 _clean_charm_data = function(data) { | |
54 data.is_subordinate = data.subordinate; | |
55 Y.each(['subordinate', 'name', 'revision', 'store_revision'], | |
56 function(nm) { delete data[nm]; }); | |
57 return data; | |
58 }; | |
59 // This is exposed for testing purposes. | |
60 models.parse_charm_id = parse_charm_id; | |
61 | |
62 // For simplicity and uniformity, there is a single Charm class and a | |
63 // single CharmList class. Charms, once instantiated and loaded with data | |
64 // from their respective sources, are immutable and read-only. This reflects | |
65 // the reality of how we interact with them. | |
66 | 11 |
67 // Charm instances can represent both environment charms and charm store | 12 // Charm instances can represent both environment charms and charm store |
68 // charms. A charm id is reliably and uniquely associated with a given | 13 // charms. A charm id is reliably and uniquely associated with a given |
69 // charm only within a given context--the environment or the charm store. | 14 // charm only within a given context--the environment or the charm store. |
70 | 15 |
71 // Therefore, the database keeps these charms separate in two different | 16 // Therefore, the database keeps these charms separate in two different |
72 // CharmList instances. One is db.charms, representing the environment | 17 // CharmList instances. One is db.charms, representing the environment |
73 // charms. The other is maintained by and within the persistent charm panel | 18 // charms. The other, from the charm store, is maintained by and within the |
74 // instance. As you'd expect, environment charms are what to use when | 19 // persistent charm panel instance. As you'd expect, environment charms are |
75 // viewing or manipulating the environment. Charm store charms are what we | 20 // what to use when viewing or manipulating the environment. Charm store |
76 // can browse to select and deploy new charms to the environment. | 21 // charms are what we can browse to select and deploy new charms to the |
| 22 // environment. |
77 | 23 |
78 // Environment charms begin their lives with full charm ids, as provided by | 24 // Charms begin their lives with full charm ids, as provided by |
79 // services in the environment: | 25 // services in the environment and the charm store: |
80 | 26 |
81 // [SCHEME]:(~[OWNER]/)?[SERIES]/[PACKAGE NAME]-[REVISION]. | 27 // [SCHEME]:(~[OWNER]/)?[SERIES]/[PACKAGE NAME]-[REVISION]. |
82 | 28 |
83 // With an id, we can instantiate a charm: typically we use | 29 // With an id, we can instantiate a charm: typically we use |
84 // "db.charms.add({id: [ID]})". Finally, we load the charm's data from the | 30 // "db.charms.add({id: [ID]})". Finally, we load the charm's data over the |
85 // environment using the standard YUI Model method "load," providing an | 31 // network using the standard YUI Model method "load," providing an object |
86 // object with a get_charm callable, and an optional callback (see YUI | 32 // with a get_charm callable, and an optional callback (see YUI docs). Both |
87 // docs). The env has a get_charm method, so, by design, it works nicely: | 33 // the env and the charm store have a get_charm method, so, by design, it |
88 // "charm.load(env, optionalCallback)". The get_charm method is expected to | 34 // works easily: "charm.load(env, optionalCallback)" or |
89 // return what the env version does: either an object with a "result" object | 35 // "charm.load(charm_store, optionalCallback)". The get_charm method must |
90 // containing the charm data, or an object with an "err" attribute. | 36 // either callback using the default YUI approach for this code, a boolean |
91 | 37 // indicating failure, and a result; or it must return what the env version |
92 // The charms in the charm store have a significant difference, beyond the | 38 // does: an object with a "result" object containing the charm data, or an |
93 // source of their data: they are addressed in the charm store by a path | 39 // object with an "err" attribute. |
94 // that does not include the revision number, and charm store searches do | |
95 // not include revision numbers in the results. Therefore, we cannot | |
96 // immediately instantiate a charm, because it requires a full id in order | |
97 // to maintain the idea of an immutable charm associated with a unique charm | |
98 // id. However, the charm information that returns does have a revision | |
99 // number (the most recent); moreover, over time the charm may be updated, | |
100 // leading to a new charm revision. We model this by creating a new charm. | |
101 | |
102 // Since we cannot create or search for charms without a revision number | |
103 // using the normal methods, the charm list has a couple of helpers for this | |
104 // story. The workhorse is "loadOneByBaseId". A "base id" is an id without | |
105 // a revision. | |
106 | |
107 // The arguments to "loadOneById" are a base id and a hash of other options. | |
108 // The hash must have a "charm_store" attribute, that itself loadByPath | |
109 // method, like the one in app/store/charm.js. It may have zero or more of | |
110 // the following: a success callback, accepting the fully loaded charm with | |
111 // the newest revision for the given base id; a failure callback, accepting | |
112 // the Y.io response object after a failure; and a "force" attribute that, | |
113 // if it is a Javascript boolean truth-y value, forces a load even if a | |
114 // charm with the given id already is in the charm list. | |
115 | |
116 // "getOneByBaseId" simply returns the charm with the highest revision and | |
117 // "the given base id from the charm list, without trying to load | |
118 // "information. | |
119 | 40 |
120 // In both cases, environment charms and charm store charms, a charm's | 41 // In both cases, environment charms and charm store charms, a charm's |
121 // "loaded" attribute is set to true once it has all the data from its | 42 // "loaded" attribute is set to true once it has all the data from its |
122 // environment. | 43 // environment. |
123 | 44 |
124 var Charm = Y.Base.create('charm', Y.Model, [], { | 45 var charmIdRe = /^(?:(\w+):)?(?:~(\S+)\/)?(\w+)\/(\S+?)-(\d+)$/, |
125 initializer: function() { | 46 idElements = ['scheme', 'owner', 'series', 'package_name', 'revision'], |
126 this.loaded = false; | 47 Charm = Y.Base.create('charm', Y.Model, [], { |
127 this.on('load', function() { this.loaded = true; }); | 48 initializer: function() { |
128 }, | 49 var id = this.get('id'), |
129 sync: function(action, options, callback) { | 50 parts = id && charmIdRe.exec(id), |
130 if (action !== 'read') { | 51 self = this; |
131 throw ( | 52 if (!Y.Lang.isValue(id) || !parts) { |
132 'Only use the "read" action; "' + action + '" not supported.'); | 53 throw 'Developers must initialize charms with a well-formed id.'; |
133 } | 54 } |
134 if (!Y.Lang.isValue(options.get_charm)) { | 55 this.loaded = false; |
135 throw 'You must supply a get_charm function.'; | 56 this.on('load', function() { this.loaded = true; }); |
136 } | 57 parts.shift(); |
137 options.get_charm( | 58 Y.each( |
138 this.get('id'), | 59 Y.Array.zip(idElements, parts), |
139 // This is the success callback, or the catch-all callback for | 60 function(pair) { self.set(pair[0], pair[1]); }); |
140 // get_charm. | 61 // full_name |
141 function(response) { | 62 var tmp = [this.get('series'), this.get('package_name')], |
142 // Handle the env.get_charm response specially, for ease of use. If | 63 owner = this.get('owner'); |
143 // it doesn't match that pattern, pass it through. | 64 if (owner) { |
144 if (response.err) { | 65 tmp.unshift('~' + owner); |
145 callback(true, response); | 66 } |
146 } else if (response.result) { | 67 this.set('full_name', tmp.join('/')); |
147 callback(false, response.result); | 68 // charm_store_path |
148 } else { // This would typically be a string. | 69 this.set( |
149 callback(false, response); | 70 'charm_store_path', |
| 71 [(owner ? '~' + owner : 'charms'), |
| 72 this.get('series'), |
| 73 (this.get('package_name') + '-' + this.get('revision')), |
| 74 'json' |
| 75 ].join('/')); |
| 76 }, |
| 77 sync: function(action, options, callback) { |
| 78 if (action !== 'read') { |
| 79 throw ( |
| 80 'Only use the "read" action; "' + action + '" not supported.'); |
| 81 } |
| 82 if (Y.Lang.isValue(options.get_charm)) { |
| 83 // This is an env. |
| 84 options.get_charm( |
| 85 this.get('id'), |
| 86 function(response) { |
| 87 if (response.err) { |
| 88 callback(true, response); |
| 89 } else if (response.result) { |
| 90 callback(false, response.result); |
| 91 } else { |
| 92 // What's going on? This does not look like either of our |
| 93 // expected signatures. Declare a loading error. |
| 94 callback(true, response); |
| 95 } |
| 96 } |
| 97 ); |
| 98 } else if (Y.Lang.isValue(options.loadByPath)) { |
| 99 // This is a charm store. |
| 100 options.loadByPath( |
| 101 this.get('charm_store_path'), |
| 102 { success: function(response) { |
| 103 callback(false, response); |
| 104 }, |
| 105 failure: function(response) { |
| 106 callback(true, response); |
| 107 } |
| 108 }); |
| 109 } else { |
| 110 throw 'You must supply a get_charm or loadByPath function.'; |
| 111 } |
| 112 }, |
| 113 parse: function() { |
| 114 var data = Charm.superclass.parse.apply(this, arguments), |
| 115 self = this; |
| 116 data.is_subordinate = data.subordinate; |
| 117 Y.each(data, function(value, key) { |
| 118 if (!value || |
| 119 !self.attrAdded(key) || |
| 120 Y.Lang.isValue(self.get(key))) { |
| 121 delete data[key]; |
| 122 } |
| 123 }); |
| 124 if (data.owner === 'charmers') { |
| 125 delete data.owner; |
| 126 } |
| 127 return data; |
| 128 }, |
| 129 compare: function(other, relevance, otherRelevance) { |
| 130 // Official charms sort before owned charms. |
| 131 // If !X.owner, that means it is owned by charmers. |
| 132 var owner = this.get('owner'), |
| 133 otherOwner = other.get('owner'); |
| 134 if (!owner && otherOwner) { |
| 135 return -1; |
| 136 } else if (owner && !otherOwner) { |
| 137 return 1; |
| 138 // Relevance is next most important. |
| 139 } else if (relevance && (relevance !== otherRelevance)) { |
| 140 // Higher relevance comes first. |
| 141 return otherRelevance - relevance; |
| 142 // Otherwise sort by package name, then by owner, then by revision. |
| 143 } else { |
| 144 return ( |
| 145 (this.get('package_name').localeCompare( |
| 146 other.get('package_name'))) || |
| 147 (owner ? owner.localeCompare(otherOwner) : 0) || |
| 148 (this.get('revision') - other.get('revision'))); |
| 149 } |
| 150 } |
| 151 }, { |
| 152 ATTRS: { |
| 153 id: { |
| 154 writeOnce: true, |
| 155 validator: function(val) { |
| 156 return Y.Lang.isString(val) && !!charmIdRe.exec(val); |
150 } | 157 } |
151 }, | 158 }, |
152 // This is the optional error callback. | 159 bzr_branch: {writeOnce: true}, |
153 function(response) { | 160 charm_store_path: {writeOnce: true}, |
154 callback(true, response); | 161 config: {writeOnce: true}, |
155 } | 162 description: {writeOnce: true}, |
156 ); | 163 full_name: {writeOnce: true}, |
157 }, | 164 is_subordinate: {writeOnce: true}, |
158 parse: function() { | 165 last_change: { |
159 return _clean_charm_data(Charm.superclass.parse.apply(this, arguments)); | 166 writeOnce: true, |
160 } | |
161 }, { | |
162 ATTRS: { | |
163 id: { | |
164 lazyAdd: false, | |
165 setter: function(val) { | |
166 if (!val) { | |
167 return val; | |
168 } | |
169 var parts = parse_charm_id(val), | |
170 self = this; | |
171 parts.revision = parseInt(parts.revision, 10); | |
172 Y.each(parts, function(value, key) { | |
173 self._set(key, value); | |
174 }); | |
175 this._set( | |
176 'charm_store_path', _calculate_charm_store_path(parts)); | |
177 this._set('full_name', _calculate_full_charm_name(parts)); | |
178 return _reconsititute_charm_id(parts); | |
179 }, | |
180 validator: function(val) { | |
181 var parts = parse_charm_id(val); | |
182 return (parts && Y.Lang.isValue(parts.revision)); | |
183 } | |
184 }, | |
185 // All of the below are loaded except as noted. | |
186 bzr_branch: {writeOnce: true}, | |
187 charm_store_path: {readOnly: true}, // calculated | |
188 config: {writeOnce: true}, | |
189 description: {writeOnce: true}, | |
190 full_name: {readOnly: true}, // calculated | |
191 is_subordinate: {writeOnce: true}, | |
192 last_change: | |
193 { writeOnce: true, | |
194 setter: function(val) { | 167 setter: function(val) { |
195 // Normalize created value from float to date object. | 168 // Normalize created value from float to date object. |
196 if (val && val.created) { | 169 if (val && val.created) { |
197 // Mutating in place should be fine since this should only | 170 // Mutating in place should be fine since this should only |
198 // come from loading over the wire. | 171 // come from loading over the wire. |
199 val.created = new Date(val.created * 1000); | 172 val.created = new Date(val.created * 1000); |
200 } | 173 } |
201 return val; | 174 return val; |
202 } | 175 } |
203 }, | 176 }, |
204 maintainer: {writeOnce: true}, | 177 maintainer: {writeOnce: true}, |
205 metadata: {writeOnce: true}, | 178 metadata: {writeOnce: true}, |
206 package_name: {readOnly: true}, // calculated | 179 package_name: {writeOnce: true}, |
207 owner: {readOnly: true}, // calculated | 180 owner: {writeOnce: true}, |
208 peers: {writeOnce: true}, | 181 peers: {writeOnce: true}, |
209 proof: {writeOnce: true}, | 182 proof: {writeOnce: true}, |
210 provides: {writeOnce: true}, | 183 provides: {writeOnce: true}, |
211 requires: {writeOnce: true}, | 184 requires: {writeOnce: true}, |
212 revision: {readOnly: true}, // calculated | 185 revision: { |
213 scheme: {readOnly: true}, // calculated | 186 writeOnce: true, |
214 series: {readOnly: true}, // calculated | 187 setter: function(val) { |
215 summary: {writeOnce: true}, | 188 if (Y.Lang.isValue(val)) { |
216 url: {writeOnce: true} | 189 val = parseInt(val, 10); |
217 } | 190 } |
218 }); | 191 return val; |
| 192 } |
| 193 }, |
| 194 scheme: { |
| 195 value: 'cs', |
| 196 writeOnce: true, |
| 197 setter: function(val) { |
| 198 if (!Y.Lang.isValue(val)) { |
| 199 val = 'cs'; |
| 200 } |
| 201 return val; |
| 202 } |
| 203 }, |
| 204 series: {writeOnce: true}, |
| 205 summary: {writeOnce: true}, |
| 206 url: {writeOnce: true} |
| 207 } |
| 208 }); |
219 models.Charm = Charm; | 209 models.Charm = Charm; |
220 | 210 |
221 var CharmList = Y.Base.create('charmList', Y.ModelList, [], { | 211 var CharmList = Y.Base.create('charmList', Y.ModelList, [], { |
222 model: Charm, | 212 model: Charm |
223 | |
224 initializer: function() { | |
225 this._baseIdHash = {}; // base id (without revision) to array of charms. | |
226 }, | |
227 | |
228 _addToBaseIdHash: function(charm) { | |
229 var baseId = charm.get('scheme') + ':' + charm.get('full_name'), | |
230 matches = this._baseIdHash[baseId]; | |
231 if (!matches) { | |
232 matches = this._baseIdHash[baseId] = []; | |
233 } | |
234 matches.push(charm); | |
235 // Note that we don't handle changing baseIds or removed charms because | |
236 // that should not happen. | |
237 // Sort on newest charms first. | |
238 matches.sort(function(a, b) { | |
239 var revA = parseInt(a.get('revision'), 10), | |
240 revB = parseInt(b.get('revision'), 10); | |
241 return revB - revA; | |
242 }); | |
243 }, | |
244 | |
245 add: function() { | |
246 var result = CharmList.superclass.add.apply(this, arguments); | |
247 if (Y.Lang.isArray(result)) { | |
248 Y.each(result, this._addToBaseIdHash, this); | |
249 } else { | |
250 this._addToBaseIdHash(result); | |
251 } | |
252 return result; | |
253 }, | |
254 | |
255 getOneByBaseId: function(id) { | |
256 var match = parse_charm_id(id), | |
257 baseId = match && _calculate_base_charm_id(match), | |
258 charms = baseId && this._baseIdHash[baseId]; | |
259 return charms && charms[0]; | |
260 }, | |
261 | |
262 loadOneByBaseId: function(id, options) { | |
263 var match = parse_charm_id(id); | |
264 if (match) { | |
265 if (!options.force) { | |
266 var charm = this.getOneByBaseId(_calculate_base_charm_id(match)); | |
267 if (charm) { | |
268 if (options.success) { | |
269 options.success(charm); | |
270 } | |
271 return; | |
272 } | |
273 } | |
274 var path = _calculate_charm_store_path(match), | |
275 self = this; | |
276 options.charm_store.loadByPath( | |
277 path, | |
278 { success: function(data) { | |
279 // We fall back to 0 for revision. Some records do not have one | |
280 // still in the charm store, such as | |
281 // http://jujucharms.com/charms/precise/appflower/json (as of this | |
282 // writing). | |
283 match.revision = data.store_revision || 0; | |
284 id = _reconsititute_charm_id(match); | |
285 charm = self.getById(id); | |
286 if (!charm) { | |
287 charm = self.add({id: id}); | |
288 charm.setAttrs(_clean_charm_data(data)); | |
289 charm.loaded = true; | |
290 } | |
291 if (options.success) { | |
292 options.success(charm); | |
293 } | |
294 }, | |
295 failure: options.failure }); | |
296 } else { | |
297 throw id + ' is not a valid base charm id'; | |
298 } | |
299 } | |
300 }, { | 213 }, { |
301 ATTRS: {} | 214 ATTRS: {} |
302 }); | 215 }); |
303 models.CharmList = CharmList; | 216 models.CharmList = CharmList; |
304 | 217 |
305 }, '0.1.0', { | 218 }, '0.1.0', { |
306 requires: [ | 219 requires: [ |
307 'model', | 220 'model', |
308 'model-list' | 221 'model-list' |
309 ] | 222 ] |
310 }); | 223 }); |
OLD | NEW |