localforage
Version:
Offline storage, improved.
541 lines (470 loc) • 20.3 kB
JavaScript
/*
* Includes code from:
*
* base64-arraybuffer
* https://github.com/niklasvh/base64-arraybuffer
*
* Copyright (c) 2012 Niklas von Hertzen
* Licensed under the MIT license.
*/
(function() {
'use strict';
// Sadly, the best way to save binary data in WebSQL is Base64 serializing
// it, so this is how we store it to prevent very strange errors with less
// verbose ways of binary <-> string data storage.
var BASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// Promises!
var Promise = (typeof module !== 'undefined' && module.exports) ?
require('promise') : this.Promise;
var openDatabase = this.openDatabase;
var SERIALIZED_MARKER = '__lfsc__:';
var SERIALIZED_MARKER_LENGTH = SERIALIZED_MARKER.length;
// OMG the serializations!
var TYPE_ARRAYBUFFER = 'arbf';
var TYPE_BLOB = 'blob';
var TYPE_INT8ARRAY = 'si08';
var TYPE_UINT8ARRAY = 'ui08';
var TYPE_UINT8CLAMPEDARRAY = 'uic8';
var TYPE_INT16ARRAY = 'si16';
var TYPE_INT32ARRAY = 'si32';
var TYPE_UINT16ARRAY = 'ur16';
var TYPE_UINT32ARRAY = 'ui32';
var TYPE_FLOAT32ARRAY = 'fl32';
var TYPE_FLOAT64ARRAY = 'fl64';
var TYPE_SERIALIZED_MARKER_LENGTH = SERIALIZED_MARKER_LENGTH +
TYPE_ARRAYBUFFER.length;
// If WebSQL methods aren't available, we can stop now.
if (!openDatabase) {
return;
}
// Open the WebSQL database (automatically creates one if one didn't
// previously exist), using any options set in the config.
function _initStorage(options) {
var self = this;
var dbInfo = {
db: null
};
if (options) {
for (var i in options) {
dbInfo[i] = typeof(options[i]) !== 'string' ?
options[i].toString() : options[i];
}
}
return new Promise(function(resolve, reject) {
// Open the database; the openDatabase API will automatically
// create it for us if it doesn't exist.
try {
dbInfo.db = openDatabase(dbInfo.name, String(dbInfo.version),
dbInfo.description, dbInfo.size);
} catch (e) {
return self.setDriver('localStorageWrapper')
.then(function() {
return self._initStorage(options);
})
.then(resolve)
.catch(reject);
}
// Create our key/value table if it doesn't exist.
dbInfo.db.transaction(function(t) {
t.executeSql('CREATE TABLE IF NOT EXISTS ' + dbInfo.storeName +
' (id INTEGER PRIMARY KEY, key unique, value)', [],
function() {
self._dbInfo = dbInfo;
resolve();
}, function(t, error) {
reject(error);
});
});
});
}
function getItem(key, callback) {
var self = this;
// Cast the key to a string, as that's all we can set as a key.
if (typeof key !== 'string') {
window.console.warn(key +
' used as a key, but it is not a string.');
key = String(key);
}
var promise = new Promise(function(resolve, reject) {
self.ready().then(function() {
var dbInfo = self._dbInfo;
dbInfo.db.transaction(function(t) {
t.executeSql('SELECT * FROM ' + dbInfo.storeName +
' WHERE key = ? LIMIT 1', [key],
function(t, results) {
var result = results.rows.length ?
results.rows.item(0).value : null;
// Check to see if this is serialized content we need to
// unpack.
if (result) {
result = _deserialize(result);
}
resolve(result);
}, function(t, error) {
reject(error);
});
});
}).catch(reject);
});
executeCallback(promise, callback);
return promise;
}
function setItem(key, value, callback) {
var self = this;
// Cast the key to a string, as that's all we can set as a key.
if (typeof key !== 'string') {
window.console.warn(key +
' used as a key, but it is not a string.');
key = String(key);
}
var promise = new Promise(function(resolve, reject) {
self.ready().then(function() {
// The localStorage API doesn't return undefined values in an
// "expected" way, so undefined is always cast to null in all
// drivers. See: https://github.com/mozilla/localForage/pull/42
if (value === undefined) {
value = null;
}
// Save the original value to pass to the callback.
var originalValue = value;
_serialize(value, function(value, error) {
if (error) {
reject(error);
} else {
var dbInfo = self._dbInfo;
dbInfo.db.transaction(function(t) {
t.executeSql('INSERT OR REPLACE INTO ' +
dbInfo.storeName +
' (key, value) VALUES (?, ?)',
[key, value], function() {
resolve(originalValue);
}, function(t, error) {
reject(error);
});
}, function(sqlError) { // The transaction failed; check
// to see if it's a quota error.
if (sqlError.code === sqlError.QUOTA_ERR) {
// We reject the callback outright for now, but
// it's worth trying to re-run the transaction.
// Even if the user accepts the prompt to use
// more storage on Safari, this error will
// be called.
//
// TODO: Try to re-run the transaction.
reject(sqlError);
}
});
}
});
}).catch(reject);
});
executeCallback(promise, callback);
return promise;
}
function removeItem(key, callback) {
var self = this;
// Cast the key to a string, as that's all we can set as a key.
if (typeof key !== 'string') {
window.console.warn(key +
' used as a key, but it is not a string.');
key = String(key);
}
var promise = new Promise(function(resolve, reject) {
self.ready().then(function() {
var dbInfo = self._dbInfo;
dbInfo.db.transaction(function(t) {
t.executeSql('DELETE FROM ' + dbInfo.storeName +
' WHERE key = ?', [key], function() {
resolve();
}, function(t, error) {
reject(error);
});
});
}).catch(reject);
});
executeCallback(promise, callback);
return promise;
}
// Deletes every item in the table.
// TODO: Find out if this resets the AUTO_INCREMENT number.
function clear(callback) {
var self = this;
var promise = new Promise(function(resolve, reject) {
self.ready().then(function() {
var dbInfo = self._dbInfo;
dbInfo.db.transaction(function(t) {
t.executeSql('DELETE FROM ' + dbInfo.storeName, [],
function() {
resolve();
}, function(t, error) {
reject(error);
});
});
}).catch(reject);
});
executeCallback(promise, callback);
return promise;
}
// Does a simple `COUNT(key)` to get the number of items stored in
// localForage.
function length(callback) {
var self = this;
var promise = new Promise(function(resolve, reject) {
self.ready().then(function() {
var dbInfo = self._dbInfo;
dbInfo.db.transaction(function(t) {
// Ahhh, SQL makes this one soooooo easy.
t.executeSql('SELECT COUNT(key) as c FROM ' +
dbInfo.storeName, [], function(t, results) {
var result = results.rows.item(0).c;
resolve(result);
}, function(t, error) {
reject(error);
});
});
}).catch(reject);
});
executeCallback(promise, callback);
return promise;
}
// Return the key located at key index X; essentially gets the key from a
// `WHERE id = ?`. This is the most efficient way I can think to implement
// this rarely-used (in my experience) part of the API, but it can seem
// inconsistent, because we do `INSERT OR REPLACE INTO` on `setItem()`, so
// the ID of each key will change every time it's updated. Perhaps a stored
// procedure for the `setItem()` SQL would solve this problem?
// TODO: Don't change ID on `setItem()`.
function key(n, callback) {
var self = this;
var promise = new Promise(function(resolve, reject) {
self.ready().then(function() {
var dbInfo = self._dbInfo;
dbInfo.db.transaction(function(t) {
t.executeSql('SELECT key FROM ' + dbInfo.storeName +
' WHERE id = ? LIMIT 1', [n + 1],
function(t, results) {
var result = results.rows.length ?
results.rows.item(0).key : null;
resolve(result);
}, function(t, error) {
reject(error);
});
});
}).catch(reject);
});
executeCallback(promise, callback);
return promise;
}
function keys(callback) {
var self = this;
var promise = new Promise(function(resolve, reject) {
self.ready().then(function() {
var dbInfo = self._dbInfo;
dbInfo.db.transaction(function(t) {
t.executeSql('SELECT key FROM ' + dbInfo.storeName, [],
function(t, results) {
var keys = [];
for (var i = 0; i < results.rows.length; i++) {
keys.push(results.rows.item(i).key);
}
resolve(keys);
}, function(t, error) {
reject(error);
});
});
}).catch(reject);
});
executeCallback(promise, callback);
return promise;
}
// Converts a buffer to a string to store, serialized, in the backend
// storage library.
function _bufferToString(buffer) {
// base64-arraybuffer
var bytes = new Uint8Array(buffer);
var i;
var base64String = '';
for (i = 0; i < bytes.length; i += 3) {
/*jslint bitwise: true */
base64String += BASE_CHARS[bytes[i] >> 2];
base64String += BASE_CHARS[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64String += BASE_CHARS[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64String += BASE_CHARS[bytes[i + 2] & 63];
}
if ((bytes.length % 3) === 2) {
base64String = base64String.substring(0, base64String.length - 1) + '=';
} else if (bytes.length % 3 === 1) {
base64String = base64String.substring(0, base64String.length - 2) + '==';
}
return base64String;
}
// Deserialize data we've inserted into a value column/field. We place
// special markers into our strings to mark them as encoded; this isn't
// as nice as a meta field, but it's the only sane thing we can do whilst
// keeping localStorage support intact.
//
// Oftentimes this will just deserialize JSON content, but if we have a
// special marker (SERIALIZED_MARKER, defined above), we will extract
// some kind of arraybuffer/binary data/typed array out of the string.
function _deserialize(value) {
// If we haven't marked this string as being specially serialized (i.e.
// something other than serialized JSON), we can just return it and be
// done with it.
if (value.substring(0,
SERIALIZED_MARKER_LENGTH) !== SERIALIZED_MARKER) {
return JSON.parse(value);
}
// The following code deals with deserializing some kind of Blob or
// TypedArray. First we separate out the type of data we're dealing
// with from the data itself.
var serializedString = value.substring(TYPE_SERIALIZED_MARKER_LENGTH);
var type = value.substring(SERIALIZED_MARKER_LENGTH,
TYPE_SERIALIZED_MARKER_LENGTH);
// Fill the string into a ArrayBuffer.
var bufferLength = serializedString.length * 0.75;
var len = serializedString.length;
var i;
var p = 0;
var encoded1, encoded2, encoded3, encoded4;
if (serializedString[serializedString.length - 1] === '=') {
bufferLength--;
if (serializedString[serializedString.length - 2] === '=') {
bufferLength--;
}
}
var buffer = new ArrayBuffer(bufferLength);
var bytes = new Uint8Array(buffer);
for (i = 0; i < len; i+=4) {
encoded1 = BASE_CHARS.indexOf(serializedString[i]);
encoded2 = BASE_CHARS.indexOf(serializedString[i+1]);
encoded3 = BASE_CHARS.indexOf(serializedString[i+2]);
encoded4 = BASE_CHARS.indexOf(serializedString[i+3]);
/*jslint bitwise: true */
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
// Return the right type based on the code/type set during
// serialization.
switch (type) {
case TYPE_ARRAYBUFFER:
return buffer;
case TYPE_BLOB:
return new Blob([buffer]);
case TYPE_INT8ARRAY:
return new Int8Array(buffer);
case TYPE_UINT8ARRAY:
return new Uint8Array(buffer);
case TYPE_UINT8CLAMPEDARRAY:
return new Uint8ClampedArray(buffer);
case TYPE_INT16ARRAY:
return new Int16Array(buffer);
case TYPE_UINT16ARRAY:
return new Uint16Array(buffer);
case TYPE_INT32ARRAY:
return new Int32Array(buffer);
case TYPE_UINT32ARRAY:
return new Uint32Array(buffer);
case TYPE_FLOAT32ARRAY:
return new Float32Array(buffer);
case TYPE_FLOAT64ARRAY:
return new Float64Array(buffer);
default:
throw new Error('Unkown type: ' + type);
}
}
// Serialize a value, afterwards executing a callback (which usually
// instructs the `setItem()` callback/promise to be executed). This is how
// we store binary data with localStorage.
function _serialize(value, callback) {
var valueString = '';
if (value) {
valueString = value.toString();
}
// Cannot use `value instanceof ArrayBuffer` or such here, as these
// checks fail when running the tests using casper.js...
//
// TODO: See why those tests fail and use a better solution.
if (value && (value.toString() === '[object ArrayBuffer]' ||
value.buffer &&
value.buffer.toString() === '[object ArrayBuffer]')) {
// Convert binary arrays to a string and prefix the string with
// a special marker.
var buffer;
var marker = SERIALIZED_MARKER;
if (value instanceof ArrayBuffer) {
buffer = value;
marker += TYPE_ARRAYBUFFER;
} else {
buffer = value.buffer;
if (valueString === '[object Int8Array]') {
marker += TYPE_INT8ARRAY;
} else if (valueString === '[object Uint8Array]') {
marker += TYPE_UINT8ARRAY;
} else if (valueString === '[object Uint8ClampedArray]') {
marker += TYPE_UINT8CLAMPEDARRAY;
} else if (valueString === '[object Int16Array]') {
marker += TYPE_INT16ARRAY;
} else if (valueString === '[object Uint16Array]') {
marker += TYPE_UINT16ARRAY;
} else if (valueString === '[object Int32Array]') {
marker += TYPE_INT32ARRAY;
} else if (valueString === '[object Uint32Array]') {
marker += TYPE_UINT32ARRAY;
} else if (valueString === '[object Float32Array]') {
marker += TYPE_FLOAT32ARRAY;
} else if (valueString === '[object Float64Array]') {
marker += TYPE_FLOAT64ARRAY;
} else {
callback(new Error('Failed to get type for BinaryArray'));
}
}
callback(marker + _bufferToString(buffer));
} else if (valueString === '[object Blob]') {
// Conver the blob to a binaryArray and then to a string.
var fileReader = new FileReader();
fileReader.onload = function() {
var str = _bufferToString(this.result);
callback(SERIALIZED_MARKER + TYPE_BLOB + str);
};
fileReader.readAsArrayBuffer(value);
} else {
try {
callback(JSON.stringify(value));
} catch (e) {
window.console.error("Couldn't convert value into a JSON " +
'string: ', value);
callback(null, e);
}
}
}
function executeCallback(promise, callback) {
if (callback) {
promise.then(function(result) {
callback(null, result);
}, function(error) {
callback(error);
});
}
}
var webSQLStorage = {
_driver: 'webSQLStorage',
_initStorage: _initStorage,
getItem: getItem,
setItem: setItem,
removeItem: removeItem,
clear: clear,
length: length,
key: key,
keys: keys
};
if (typeof define === 'function' && define.amd) {
define('webSQLStorage', function() {
return webSQLStorage;
});
} else if (typeof module !== 'undefined' && module.exports) {
module.exports = webSQLStorage;
} else {
this.webSQLStorage = webSQLStorage;
}
}).call(window);