/* * Copyright 2014, Nexedi SA * * This program is free software: you can Use, Study, Modify and Redistribute * it under the terms of the GNU General Public License version 3, or (at your * option) any later version, as published by the Free Software Foundation. * * You can also Link and Combine this program with other software covered by * the terms of any of the Free Software licenses or any of the Open Source * Initiative approved licenses and Convey the resulting work. Corresponding * source of such a combination shall include the source code for all other * software used. * * This program is distributed WITHOUT ANY WARRANTY; without even the implied * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * * See COPYING file for full licensing terms. * See https://www.nexedi.com/licensing for rationale and options. */ /** * JIO Indexed Database Storage. * * A local browser "database" storage greatly more powerful than localStorage. * * Description: * * { * "type": "indexeddb", * "database": <string> * } * * The database name will be prefixed by "jio:", so if the database property is * "hello", then you can manually reach this database with * `indexedDB.open("jio:hello");`. (Or * `indexedDB.deleteDatabase("jio:hello");`.) * * For more informations: * * - http://www.w3.org/TR/IndexedDB/ * - https://developer.mozilla.org/en-US/docs/IndexedDB/Using_IndexedDB */ /*jslint nomen: true */ /*global indexedDB, jIO, RSVP, Blob, Math, IDBKeyRange, IDBOpenDBRequest, DOMError, Event*/ (function (indexedDB, jIO, RSVP, Blob, Math, IDBKeyRange, IDBOpenDBRequest, DOMError) { "use strict"; // Read only as changing it can lead to data corruption var UNITE = 2000000; function IndexedDBStorage(description) { if (typeof description.database !== "string" || description.database === "") { throw new TypeError("IndexedDBStorage 'database' description property " + "must be a non-empty string"); } this._database_name = "jio:" + description.database; } IndexedDBStorage.prototype.hasCapacity = function (name) { return ((name === "list") || (name === "include")); }; function buildKeyPath(key_list) { return key_list.join("_"); } function handleUpgradeNeeded(evt) { var db = evt.target.result, store; store = db.createObjectStore("metadata", { keyPath: "_id", autoIncrement: false }); // It is not possible to use openKeyCursor on keypath directly // https://www.w3.org/Bugs/Public/show_bug.cgi?id=19955 store.createIndex("_id", "_id", {unique: true}); store = db.createObjectStore("attachment", { keyPath: "_key_path", autoIncrement: false }); store.createIndex("_id", "_id", {unique: false}); store = db.createObjectStore("blob", { keyPath: "_key_path", autoIncrement: false }); store.createIndex("_id_attachment", ["_id", "_attachment"], {unique: false}); store.createIndex("_id", "_id", {unique: false}); } function openIndexedDB(jio_storage) { var db_name = jio_storage._database_name; function resolver(resolve, reject) { // Open DB // var request = indexedDB.open(db_name); request.onerror = function (error) { if (request.result) { request.result.close(); } if ((error !== undefined) && (error.target instanceof IDBOpenDBRequest) && (error.target.error instanceof DOMError)) { reject("Connection to: " + db_name + " failed: " + error.target.error.message); } else { reject(error); } }; request.onabort = function () { request.result.close(); reject("Aborting connection to: " + db_name); }; request.ontimeout = function () { request.result.close(); reject("Connection to: " + db_name + " timeout"); }; request.onblocked = function () { request.result.close(); reject("Connection to: " + db_name + " was blocked"); }; // Create DB if necessary // request.onupgradeneeded = handleUpgradeNeeded; request.onversionchange = function () { request.result.close(); reject(db_name + " was upgraded"); }; request.onsuccess = function () { resolve(request.result); }; } // XXX Canceller??? return new RSVP.Queue() .push(function () { return new RSVP.Promise(resolver); }); } function openTransaction(db, stores, flag, autoclosedb) { var tx = db.transaction(stores, flag); if (autoclosedb !== false) { tx.oncomplete = function () { db.close(); }; } tx.onabort = function () { db.close(); }; return tx; } function handleCursor(request, callback, resolve, reject) { request.onerror = function (error) { if (request.transaction) { request.transaction.abort(); } reject(error); }; request.onsuccess = function (evt) { var cursor = evt.target.result; if (cursor) { // XXX Wait for result try { callback(cursor); } catch (error) { reject(error); } // continue to next iteration cursor["continue"](); } else { resolve(); } }; } IndexedDBStorage.prototype.buildQuery = function (options) { var result_list = []; function pushIncludedMetadata(cursor) { result_list.push({ "id": cursor.key, "value": {}, "doc": cursor.value.doc }); } function pushMetadata(cursor) { result_list.push({ "id": cursor.key, "value": {} }); } return openIndexedDB(this) .push(function (db) { return new RSVP.Promise(function (resolve, reject) { var tx = openTransaction(db, ["metadata"], "readonly"); if (options.include_docs === true) { handleCursor(tx.objectStore("metadata").index("_id").openCursor(), pushIncludedMetadata, resolve, reject); } else { handleCursor(tx.objectStore("metadata").index("_id") .openKeyCursor(), pushMetadata, resolve, reject); } }); }) .push(function () { return result_list; }); }; function handleGet(store, id, resolve, reject) { var request = store.get(id); request.onerror = reject; request.onsuccess = function () { if (request.result) { resolve(request.result); } else { reject(new jIO.util.jIOError( "IndexedDB: cannot find object '" + id + "' in the '" + store.name + "' store", 404 )); } }; } IndexedDBStorage.prototype.get = function (id) { return openIndexedDB(this) .push(function (db) { return new RSVP.Promise(function (resolve, reject) { var transaction = openTransaction(db, ["metadata"], "readonly"); handleGet( transaction.objectStore("metadata"), id, resolve, reject ); }); }) .push(function (result) { return result.doc; }); }; IndexedDBStorage.prototype.allAttachments = function (id) { var attachment_dict = {}; function addEntry(cursor) { attachment_dict[cursor.value._attachment] = {}; } return openIndexedDB(this) .push(function (db) { return new RSVP.Promise(function (resolve, reject) { var transaction = openTransaction(db, ["metadata", "attachment"], "readonly"); function getAttachments() { handleCursor( transaction.objectStore("attachment").index("_id") .openCursor(IDBKeyRange.only(id)), addEntry, resolve, reject ); } handleGet( transaction.objectStore("metadata"), id, getAttachments, reject ); }); }) .push(function () { return attachment_dict; }); }; function handleRequest(request, resolve, reject) { request.onerror = reject; request.onsuccess = function () { resolve(request.result); }; } IndexedDBStorage.prototype.put = function (id, metadata) { return openIndexedDB(this) .push(function (db) { return new RSVP.Promise(function (resolve, reject) { var transaction = openTransaction(db, ["metadata"], "readwrite"); handleRequest( transaction.objectStore("metadata").put({ "_id": id, "doc": metadata }), resolve, reject ); }); }); }; function deleteEntry(cursor) { cursor["delete"](); } IndexedDBStorage.prototype.remove = function (id) { var resolved_amount = 0; return openIndexedDB(this) .push(function (db) { return new RSVP.Promise(function (resolve, reject) { function resolver() { if (resolved_amount < 2) { resolved_amount += 1; } else { resolve(); } } var transaction = openTransaction(db, ["metadata", "attachment", "blob"], "readwrite"); handleRequest( transaction.objectStore("metadata")["delete"](id), resolver, reject ); // XXX Why not possible to delete with KeyCursor? handleCursor(transaction.objectStore("attachment").index("_id") .openCursor(IDBKeyRange.only(id)), deleteEntry, resolver, reject ); handleCursor(transaction.objectStore("blob").index("_id") .openCursor(IDBKeyRange.only(id)), deleteEntry, resolver, reject ); }); }); }; IndexedDBStorage.prototype.getAttachment = function (id, name, options) { var transaction, type, start, end; if (options === undefined) { options = {}; } return openIndexedDB(this) .push(function (db) { return new RSVP.Promise(function (resolve, reject) { transaction = openTransaction( db, ["attachment", "blob"], "readonly" ); function getBlob(attachment) { var total_length = attachment.info.length, result_list = [], store = transaction.objectStore("blob"), start_index, end_index; type = attachment.info.content_type; start = options.start || 0; end = options.end || total_length; if (end > total_length) { end = total_length; } if (start < 0 || end < 0) { throw new jIO.util.jIOError( "_start and _end must be positive", 400 ); } if (start > end) { throw new jIO.util.jIOError("_start is greater than _end", 400); } start_index = Math.floor(start / UNITE); end_index = Math.floor(end / UNITE) - 1; if (end % UNITE === 0) { end_index -= 1; } function resolver(result) { if (result.blob !== undefined) { result_list.push(result); } resolve(result_list); } function getPart(i) { return function (result) { if (result) { result_list.push(result); } i += 1; handleGet(store, buildKeyPath([id, name, i]), (i <= end_index) ? getPart(i) : resolver, reject ); }; } getPart(start_index - 1)(); } // XXX Should raise if key is not good handleGet(transaction.objectStore("attachment"), buildKeyPath([id, name]), getBlob, reject ); }); }) .push(function (result_list) { var array_buffer_list = [], blob, i, index, len = result_list.length; for (i = 0; i < len; i += 1) { array_buffer_list.push(result_list[i].blob); } if ((options.start === undefined) && (options.end === undefined)) { return new Blob(array_buffer_list, {type: type}); } index = Math.floor(start / UNITE) * UNITE; blob = new Blob(array_buffer_list, {type: "application/octet-stream"}); return blob.slice(start - index, end - index, "application/octet-stream"); }); }; function removeAttachment(transaction, id, name, resolve, reject) { // XXX How to get the right attachment function deleteContent() { handleCursor( transaction.objectStore("blob").index("_id_attachment") .openCursor(IDBKeyRange.only([id, name])), deleteEntry, resolve, reject ); } handleRequest( transaction.objectStore("attachment")["delete"]( buildKeyPath([id, name]) ), deleteContent, reject ); } IndexedDBStorage.prototype.putAttachment = function (id, name, blob) { var blob_part = [], transaction, db; return openIndexedDB(this) .push(function (database) { db = database; // Split the blob first return jIO.util.readBlobAsArrayBuffer(blob); }) .push(function (event) { var array_buffer = event.target.result, total_size = blob.size, handled_size = 0; while (handled_size < total_size) { blob_part.push(array_buffer.slice(handled_size, handled_size + UNITE)); handled_size += UNITE; } // Remove previous attachment transaction = openTransaction(db, ["attachment", "blob"], "readwrite"); return new RSVP.Promise(function (resolve, reject) { function write() { var len = blob_part.length - 1, attachment_store = transaction.objectStore("attachment"), blob_store = transaction.objectStore("blob"); function putBlobPart(i) { return function () { i += 1; handleRequest( blob_store.put({ "_key_path": buildKeyPath([id, name, i]), "_id" : id, "_attachment" : name, "_part" : i, "blob": blob_part[i] }), (i < len) ? putBlobPart(i) : resolve, reject ); }; } handleRequest( attachment_store.put({ "_key_path": buildKeyPath([id, name]), "_id": id, "_attachment": name, "info": { "content_type": blob.type, "length": blob.size } }), putBlobPart(-1), reject ); } removeAttachment(transaction, id, name, write, reject); }); }); }; IndexedDBStorage.prototype.removeAttachment = function (id, name) { return openIndexedDB(this) .push(function (db) { var transaction = openTransaction(db, ["attachment", "blob"], "readwrite"); return new RSVP.Promise(function (resolve, reject) { removeAttachment(transaction, id, name, resolve, reject); }); }); }; jIO.addStorage("indexeddb", IndexedDBStorage); }(indexedDB, jIO, RSVP, Blob, Math, IDBKeyRange, IDBOpenDBRequest, DOMError));