packages/skygear-core/lib/store.js
/**
* Copyright 2015 Oursky Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const cookieKeyWhiteList = [
'skygear-deviceid',
'skygear-user',
'skygear-accesstoken',
'skygear-oauth-redirect-action'
];
var store;
const PURGEABLE_KEYS_KEY = '_skygear_purgeable_keys_';
import {CookieStorage} from 'cookie-storage';
import {isLocalStorageValid} from './util';
/**
* @private
*/
class SyncStorageDriver {
constructor(syncImpl) {
this._syncImpl = syncImpl;
}
async clear(callback) {
await this._syncImpl.clear();
if (callback) {
callback(null);
}
}
async getItem(key, callback) {
const value = await this._syncImpl.getItem(key);
if (callback) {
callback(null, value);
}
return value;
}
async setItem(key, value, callback) {
try {
await this._syncImpl.setItem(key, value);
if (callback) {
callback(null);
}
} catch (e) {
if (callback) {
callback(e);
}
throw e;
}
}
async removeItem(key, callback) {
await this._syncImpl.removeItem(key);
if (callback) {
callback(null);
}
}
async multiGet(keys, callback) {
const output = [];
for (let i = 0; i < keys.length; ++i) {
const key = keys[i];
const value = await this._syncImpl.getItem(key);
output.push({
key: key,
value: value
});
}
if (callback) {
callback(null, output);
}
return output;
}
async multiSet(keyValuePairs, callback) {
try {
for (let i = 0; i < keyValuePairs.length; ++i) {
const pair = keyValuePairs[i];
const key = pair.key;
const value = pair.value;
await this._syncImpl.setItem(key, value);
}
if (callback) {
callback(null);
}
} catch (e) {
if (callback) {
callback(e);
}
throw e;
}
}
async multiRemove(keys, callback) {
for (let i = 0; i < keys.length; ++i) {
const key = keys[i];
await this._syncImpl.removeItem(key);
}
if (callback) {
callback(null);
}
}
async key(n, callback) {
const result = await this._syncImpl.key(n);
if (callback) {
callback(null, result);
}
return result;
}
async keys(callback) {
const length = this._syncImpl.length;
const output = [];
for (let i = 0; i < length; ++i) {
output.push(await this._syncImpl.key(i));
}
if (callback) {
callback(null, output);
}
return output;
}
async length(callback) {
const length = this._syncImpl.length;
if (callback) {
callback(null, length);
}
return length;
}
}
/**
* @private
*/
export class Store {
constructor(driver, keyWhiteList) {
this._driver = driver;
this.keyWhiteList = keyWhiteList;
this._purgeableKeys = [];
(async () => {
const value = await this._driver.getItem(PURGEABLE_KEYS_KEY);
if (value) {
try {
const originalKeys = JSON.parse(value);
const recentKeys = this._purgeableKeys;
this._purgeableKeys = this._maintainLRUOrder(
originalKeys,
recentKeys
);
} catch (e) {
// ignore
}
}
})();
}
/*
* @param originalKeys
* @param recentKeys
* @return newKeys with recentKeys come first, followed by deduped
* originalKeys
*/
_maintainLRUOrder(originalKeys, recentKeys) {
const mapping = {};
for (let i = 0; i < recentKeys.length; ++i) {
mapping[recentKeys[i]] = true;
}
const output = recentKeys.slice();
for (let i = 0; i < originalKeys.length; ++i) {
if (mapping[originalKeys[i]]) {
continue;
}
output.push(originalKeys[i]);
}
return output;
}
/*
* @param originalKeys
* @param keysToRemove
* @return newKeys without value contained in keysToRemove
*/
_removeKeysInLRUOrder(originalKeys, keysToRemove) {
const mapping = {};
for (let i = 0; i < keysToRemove.length; ++i) {
mapping[keysToRemove[i]] = true;
}
const output = [];
for (let i = 0; i < originalKeys.length; ++i) {
if (mapping[originalKeys[i]]) {
continue;
}
output.push(originalKeys[i]);
}
return output;
}
async clear(callback) {
return this._driver.clear(callback);
}
async getItem(key, callback) {
return this._driver.getItem(key, callback);
}
async setItem(key, value, callback) {
if (this.keyWhiteList && this.keyWhiteList.indexOf(key) < 0) {
throw new Error('Saving key is not permitted');
}
try {
await this._driver.setItem(key, value);
if (callback) {
callback(null);
}
} catch (error) {
let lastError = error;
try {
await this._purge();
} catch (innerError) {
lastError = innerError;
}
if (callback) {
callback(lastError);
}
throw lastError;
}
}
async setPurgeableItem(key, value, callback) {
if (this.keyWhiteList && this.keyWhiteList.indexOf(key) < 0) {
throw new Error('Saving key is not permitted');
}
this._purgeableKeys = this._maintainLRUOrder(
this._purgeableKeys, [key]
);
const keyValuePairs = [
{
key: key,
value: value
},
{
key: PURGEABLE_KEYS_KEY,
value: JSON.stringify(this._purgeableKeys)
}
];
try {
await this.multiSetTransactionally(keyValuePairs);
if (callback) {
callback(null);
}
} catch (error) {
let lastError = error;
try {
await this._purge();
} catch (innerError) {
lastError = innerError;
}
if (callback) {
callback(lastError);
}
throw lastError;
}
}
_selectKeysToPurge(keys) {
const index = Math.floor(keys.length / 2);
const keysToPurge = keys.slice(index);
return keysToPurge;
}
async _purge() {
const keysToPurge = this._selectKeysToPurge(this._purgeableKeys);
if (keysToPurge.length <= 0) {
throw new Error('no more keys to purge');
}
this._purgeableKeys = this._removeKeysInLRUOrder(
this._purgeableKeys,
keysToPurge
);
await this._driver.multiRemove(keysToPurge);
return this._driver.setItem(
PURGEABLE_KEYS_KEY,
JSON.stringify(this._purgeableKeys)
);
}
async multiSetTransactionally(keyValuePairs, callback) {
const keys = [];
for (let i = 0; i < keyValuePairs.length; ++i) {
const pair = keyValuePairs[i];
const key = pair.key;
if (this.keyWhiteList && this.keyWhiteList.indexOf(key) < 0) {
throw new Error('Saving key is not permitted');
}
keys.push(key);
}
const original = await this._driver.multiGet(keys);
try {
await this._driver.multiSet(keyValuePairs);
if (callback) {
callback(null);
}
return;
} catch (e) {
await this._driver.multiRemove(keys);
await this._driver.multiSet(original);
if (callback) {
callback(e);
}
throw e;
}
}
async clearPurgeableItems(callback) {
const keys = this._purgeableKeys.slice();
this._purgeableKeys = [];
return this._driver.multiRemove(keys, callback);
}
async removeItem(key, callback) {
return this._driver.removeItem(key, callback);
}
async key(n, callback) {
return this._driver.key(n, callback);
}
async keys(callback) {
return this._driver.keys(callback);
}
async length(callback) {
return this._driver.length(callback);
}
}
/**
* @private
*/
export const setStore = (_store) => {
store = _store;
};
/**
* @private
*/
export default () => {
if (store) {
return store;
}
/* global window: false */
if (typeof window !== 'undefined') {
// env: browser-like
if (isLocalStorageValid()) {
// env: Modern browsers
store = new Store(new SyncStorageDriver(window.localStorage));
} else {
// env: Legacy browsers
var cookieImpl = new CookieStorage();
store = new Store(new SyncStorageDriver(cookieImpl, cookieKeyWhiteList));
}
} else {
// env: node
var memoryImpl = require('localstorage-memory');
store = new Store(new SyncStorageDriver(memoryImpl));
}
return store;
};