Merge pull request #512 from galfert/node_binary_fix
[remotestorage.js] / src / indexeddb.js
1 (function(global) {
2
3   /**
4    * Class: RemoteStorage.IndexedDB
5    *
6    *
7    * IndexedDB Interface
8    * -------------------
9    *
10    * This file exposes a get/put/delete interface, accessing data in an indexedDB.
11    *
12    * There are multiple parts to this interface:
13    *
14    *   The RemoteStorage integration:
15    *     - RemoteStorage.IndexedDB._rs_supported() determines if indexedDB support
16    *       is available. If it isn't, RemoteStorage won't initialize the feature.
17    *     - RemoteStorage.IndexedDB._rs_init() initializes the feature. It returns
18    *       a promise that is fulfilled as soon as the database has been opened and
19    *       migrated.
20    *
21    *   The storage interface (RemoteStorage.IndexedDB object):
22    *     - Usually this is accessible via "remoteStorage.local"
23    *     - #get() takes a path and returns a promise.
24    *     - #put() takes a path, body and contentType and also returns a promise.
25    *       In addition it also takes a 'incoming' flag, which indicates that the
26    *       change is not fresh, but synchronized from remote.
27    *     - #delete() takes a path and also returns a promise. It also supports
28    *       the 'incoming' flag described for #put().
29    *     - #on('change', ...) events, being fired whenever something changes in
30    *       the storage. Change events roughly follow the StorageEvent pattern.
31    *       They have "oldValue" and "newValue" properties, which can be used to
32    *       distinguish create/update/delete operations and analyze changes in
33    *       change handlers. In addition they carry a "origin" property, which
34    *       is either "window", "local", or "remote". "remote" events are fired
35    *       whenever the "incoming" flag is passed to #put() or #delete(). This
36    *       is usually done by RemoteStorage.Sync.
37    *
38    *   The revision interface (also on RemoteStorage.IndexedDB object):
39    *     - #setRevision(path, revision) sets the current revision for the given
40    *       path. Revisions are only generated by the remotestorage server, so
41    *       this is usually done from RemoteStorage.Sync once a pending change
42    *       has been pushed out.
43    *     - #setRevisions(revisions) takes path/revision pairs in the form:
44    *       [[path1, rev1], [path2, rev2], ...] and updates all revisions in a
45    *       single transaction.
46    *     - #getRevision(path) returns the currently stored revision for the given
47    *       path.
48    *
49    *   The changes interface (also on RemoteStorage.IndexedDB object):
50    *     - Used to record local changes between sync cycles.
51    *     - Changes are stored in a separate ObjectStore called "changes".
52    *     - #_recordChange() records a change and is called by #put() and #delete(),
53    *       given the "incoming" flag evaluates to false. It is private and should
54    *       never be used from the outside.
55    *     - #changesBelow() takes a path and returns a promise that will be fulfilled
56    *       with an Array of changes that are pending for the given path or below.
57    *       This is usually done in a sync cycle to push out pending changes.
58    *     - #clearChange removes the change for a given path. This is usually done
59    *       RemoteStorage.Sync once a change has successfully been pushed out.
60    *     - #setConflict sets conflict attributes on a change. It also fires the
61    *       "conflict" event.
62    *     - #on('conflict', ...) event. Conflict events usually have the following
63    *       attributes: path, localAction and remoteAction. Both actions are either
64    *       "PUT" or "DELETE". They also bring a "resolve" method, which can be
65    *       called with either of the strings "remote" and "local" to mark the
66    *       conflict as resolved. The actual resolution will usually take place in
67    *       the next sync cycle.
68    */
69
70   var RS = RemoteStorage;
71
72   var DEFAULT_DB_NAME = 'remotestorage';
73   var DEFAULT_DB;
74
75   function keepDirNode(node) {
76     return Object.keys(node.body).length > 0 ||
77       Object.keys(node.cached).length > 0;
78   }
79
80   function removeFromParent(nodes, path, key) {
81     var parts = path.match(/^(.*\/)([^\/]+\/?)$/);
82     if (parts) {
83       var dirname = parts[1], basename = parts[2];
84       nodes.get(dirname).onsuccess = function(evt) {
85         var node = evt.target.result;
86         if (!node) {//attempt to remove something from a non-existing directory
87           return;
88         }
89         delete node[key][basename];
90         if (keepDirNode(node)) {
91           nodes.put(node);
92         } else {
93           nodes.delete(node.path).onsuccess = function() {
94             if (dirname !== '/') {
95               removeFromParent(nodes, dirname, key);
96             }
97           };
98         }
99       };
100     }
101   }
102
103   function makeNode(path) {
104     var node = { path: path };
105     if (path[path.length - 1] === '/') {
106       node.body = {};
107       node.cached = {};
108       node.contentType = 'application/json';
109     }
110     return node;
111   }
112
113   function addToParent(nodes, path, key, revision) {
114     var parts = path.match(/^(.*\/)([^\/]+\/?)$/);
115     if (parts) {
116       var dirname = parts[1], basename = parts[2];
117       nodes.get(dirname).onsuccess = function(evt) {
118         var node = evt.target.result || makeNode(dirname);
119         node[key][basename] = revision || true;
120         nodes.put(node).onsuccess = function() {
121           if (dirname !== '/') {
122             addToParent(nodes, dirname, key, true);
123           }
124         };
125       };
126     }
127   }
128
129   RS.IndexedDB = function(database) {
130     this.db = database || DEFAULT_DB;
131     if (! this.db) {
132       RemoteStorage.log("Failed to open indexedDB");
133       return undefined;
134     }
135     RS.cachingLayer(this);
136     RS.eventHandling(this, 'change', 'conflict');
137   };
138
139   RS.IndexedDB.prototype = {
140
141     get: function(path) {
142       var promise = promising();
143       var transaction = this.db.transaction(['nodes'], 'readonly');
144       var nodes = transaction.objectStore('nodes');
145       var nodeReq = nodes.get(path);
146       var node;
147
148       nodeReq.onsuccess = function() {
149         node = nodeReq.result;
150       };
151
152       transaction.oncomplete = function() {
153         if (node) {
154           promise.fulfill(200, node.body, node.contentType, node.revision);
155         } else {
156           promise.fulfill(404);
157         }
158       };
159
160       transaction.onerror = transaction.onabort = promise.reject;
161       return promise;
162     },
163
164     put: function(path, body, contentType, incoming, revision) {
165       var promise = promising();
166       if (path[path.length - 1] === '/') { throw "Bad: don't PUT folders"; }
167       var transaction = this.db.transaction(['nodes'], 'readwrite');
168       var nodes = transaction.objectStore('nodes');
169       var oldNode;
170       var done;
171
172       nodes.get(path).onsuccess = function(evt) {
173         try {
174           oldNode = evt.target.result;
175           var node = {
176             path: path,
177             contentType: contentType,
178             body: body
179           };
180           nodes.put(node).onsuccess = function() {
181             try {
182               addToParent(nodes, path, 'body', revision);
183             } catch(e) {
184               if (typeof(done) === 'undefined') {
185                 done = true;
186                 promise.reject(e);
187               }
188             }
189           };
190         } catch(e) {
191           if (typeof(done) === 'undefined') {
192             done = true;
193             promise.reject(e);
194           }
195         }
196       };
197
198       transaction.oncomplete = function() {
199         this._emit('change', {
200           path: path,
201           origin: incoming ? 'remote' : 'window',
202           oldValue: oldNode ? oldNode.body : undefined,
203           newValue: body
204         });
205         if (! incoming) {
206           this._recordChange(path, { action: 'PUT', revision: oldNode ? oldNode.revision : undefined });
207         }
208         if (typeof(done) === 'undefined') {
209           done = true;
210           promise.fulfill(200);
211         }
212       }.bind(this);
213
214       transaction.onerror = transaction.onabort = promise.reject;
215       return promise;
216     },
217
218     delete: function(path, incoming) {
219       var promise = promising();
220       if (path[path.length - 1] === '/') { throw "Bad: don't DELETE folders"; }
221       var transaction = this.db.transaction(['nodes'], 'readwrite');
222       var nodes = transaction.objectStore('nodes');
223       var oldNode;
224
225       nodes.get(path).onsuccess = function(evt) {
226         oldNode = evt.target.result;
227         nodes.delete(path).onsuccess = function() {
228           removeFromParent(nodes, path, 'body', incoming);
229         };
230       };
231
232       transaction.oncomplete = function() {
233         if (oldNode) {
234           this._emit('change', {
235             path: path,
236             origin: incoming ? 'remote' : 'window',
237             oldValue: oldNode.body,
238             newValue: undefined
239           });
240         }
241         if (! incoming) {
242           this._recordChange(path, { action: 'DELETE', revision: oldNode ? oldNode.revision : undefined });
243         }
244         promise.fulfill(200);
245       }.bind(this);
246
247       transaction.onerror = transaction.onabort = promise.reject;
248       return promise;
249     },
250
251     setRevision: function(path, revision) {
252       return this.setRevisions([[path, revision]]);
253     },
254
255     setRevisions: function(revs) {
256       var promise = promising();
257       var transaction = this.db.transaction(['nodes'], 'readwrite');
258
259       revs.forEach(function(rev) {
260         var nodes = transaction.objectStore('nodes');
261         nodes.get(rev[0]).onsuccess = function(event) {
262           var node = event.target.result || makeNode(rev[0]);
263           node.revision = rev[1];
264           nodes.put(node).onsuccess = function() {
265             addToParent(nodes, rev[0], 'cached', rev[1]);
266           };
267         };
268       });
269
270       transaction.oncomplete = function() {
271         promise.fulfill();
272       };
273
274       transaction.onerror = transaction.onabort = promise.reject;
275       return promise;
276     },
277
278     getRevision: function(path) {
279       var promise = promising();
280       var transaction = this.db.transaction(['nodes'], 'readonly');
281       var rev;
282
283       transaction.objectStore('nodes').
284         get(path).onsuccess = function(evt) {
285           if (evt.target.result) {
286             rev = evt.target.result.revision;
287           }
288         };
289
290       transaction.oncomplete = function() {
291         promise.fulfill(rev);
292       };
293
294       transaction.onerror = transaction.onabort = promise.reject;
295       return promise;
296     },
297
298     getCached: function(path) {
299       if (path[path.length - 1] !== '/') {
300         return this.get(path);
301       }
302       var promise = promising();
303       var transaction = this.db.transaction(['nodes'], 'readonly');
304       var nodes = transaction.objectStore('nodes');
305
306       nodes.get(path).onsuccess = function(evt) {
307         var node = evt.target.result || {};
308         promise.fulfill(200, node.cached, node.contentType, node.revision);
309       };
310
311       return promise;
312     },
313
314     reset: function(callback) {
315       var dbName = this.db.name;
316       this.db.close();
317       var self = this;
318       RS.IndexedDB.clean(this.db.name, function() {
319         RS.IndexedDB.open(dbName, function(other) {
320           // hacky!
321           self.db = other.db;
322           callback(self);
323         });
324       });
325     },
326
327     fireInitial: function() {
328       var transaction = this.db.transaction(['nodes'], 'readonly');
329       var cursorReq = transaction.objectStore('nodes').openCursor();
330       cursorReq.onsuccess = function(evt) {
331         var cursor = evt.target.result;
332         if (cursor) {
333           var path = cursor.key;
334           if (path.substr(-1) !== '/') {
335             this._emit('change', {
336               path: path,
337               origin: 'local',
338               oldValue: undefined,
339               newValue: cursor.value.body
340             });
341           }
342           cursor.continue();
343         }
344       }.bind(this);
345     },
346
347     _recordChange: function(path, attributes) {
348       var promise = promising();
349       var transaction = this.db.transaction(['changes'], 'readwrite');
350       var changes = transaction.objectStore('changes');
351       var change;
352
353       changes.get(path).onsuccess = function(evt) {
354         change = evt.target.result || {};
355         change.path = path;
356         for(var key in attributes) {
357           change[key] = attributes[key];
358         }
359         changes.put(change);
360       };
361
362       transaction.oncomplete = promise.fulfill;
363       transaction.onerror = transaction.onabort = promise.reject;
364       return promise;
365     },
366
367     clearChange: function(path) {
368       var promise = promising();
369       var transaction = this.db.transaction(['changes'], 'readwrite');
370       var changes = transaction.objectStore('changes');
371       changes.delete(path);
372
373       transaction.oncomplete = function() {
374         promise.fulfill();
375       };
376
377       return promise;
378     },
379
380     changesBelow: function(path) {
381       var promise = promising();
382       var transaction = this.db.transaction(['changes'], 'readonly');
383       var cursorReq = transaction.objectStore('changes').
384         openCursor(IDBKeyRange.lowerBound(path));
385       var pl = path.length;
386       var changes = [];
387
388       cursorReq.onsuccess = function() {
389         var cursor = cursorReq.result;
390         if (cursor) {
391           if (cursor.key.substr(0, pl) === path) {
392             changes.push(cursor.value);
393             cursor.continue();
394           }
395         }
396       };
397
398       transaction.oncomplete = function() {
399         promise.fulfill(changes);
400       };
401
402       return promise;
403     },
404
405     setConflict: function(path, attributes) {
406       var event = this._createConflictEvent(path, attributes);
407       this._recordChange(path, { conflict: attributes }).
408         then(function() {
409           // fire conflict once conflict has been recorded.
410           if (this._handlers.conflict.length > 0) {
411             this._emit('conflict', event);
412           } else {
413             setTimeout(function() { event.resolve('remote'); }, 0);
414           }
415         }.bind(this));
416     },
417
418     closeDB: function() {
419       this.db.close();
420     }
421
422   };
423
424   var DB_VERSION = 2;
425
426   RS.IndexedDB.open = function(name, callback) {
427     var timer = setTimeout(function() {
428       callback("timeout trying to open db");
429     }, 3500);
430
431     var dbOpen = indexedDB.open(name, DB_VERSION);
432
433     dbOpen.onerror = function() {
434       RemoteStorage.log('opening db failed', dbOpen);
435       clearTimeout(timer);
436       callback(dbOpen.error);
437     };
438
439     dbOpen.onupgradeneeded = function(event) {
440       RemoteStorage.log("[IndexedDB] Upgrade: from ", event.oldVersion, " to ", event.newVersion);
441       var db = dbOpen.result;
442       if (event.oldVersion !== 1) {
443         RemoteStorage.log("[IndexedDB] Creating object store: nodes");
444         db.createObjectStore('nodes', { keyPath: 'path' });
445       }
446       RemoteStorage.log("[IndexedDB] Creating object store: changes");
447       db.createObjectStore('changes', { keyPath: 'path' });
448     };
449
450     dbOpen.onsuccess = function() {
451       clearTimeout(timer);
452       callback(null, dbOpen.result);
453     };
454   };
455
456   RS.IndexedDB.clean = function(databaseName, callback) {
457     var req = indexedDB.deleteDatabase(databaseName);
458     req.onsuccess = function() {
459       RemoteStorage.log('done removing db');
460       callback();
461     };
462     req.onerror = req.onabort = function(evt) {
463       console.error('failed to remove database "' + databaseName + '"', evt);
464     };
465   };
466
467   RS.IndexedDB._rs_init = function(remoteStorage) {
468     var promise = promising();
469     RS.IndexedDB.open(DEFAULT_DB_NAME, function(err, db) {
470       if(err) {
471         promise.reject(err);
472       } else {
473         DEFAULT_DB = db;
474         db.onerror = function() { remoteStorage._emit('error', err); };
475         promise.fulfill();
476       }
477     });
478
479     return promise;
480   };
481
482   RS.IndexedDB._rs_supported = function() {
483     return 'indexedDB' in global;
484   };
485
486   RS.IndexedDB._rs_cleanup = function(remoteStorage) {
487     if (remoteStorage.local) {
488       remoteStorage.local.closeDB();
489     }
490     var promise = promising();
491     RS.IndexedDB.clean(DEFAULT_DB_NAME, function() {
492       promise.fulfill();
493     });
494     return promise;
495   };
496
497 })(typeof(window) !== 'undefined' ? window : global);