All Downloads are FREE. Search and download functionalities are using the official Maven repository.

node_modules.node-persist.src.local-storage.js Maven / Gradle / Ivy

There is a newer version: 1.4.0
Show newest version
/*
 * Simon Last, Sept 2013
 * http://simonlast.org
 */

var fs     = require('fs'),
    path   = require('path'),
    crypto   = require('crypto'),
    mkdirp = require('mkdirp'),
    isAbsolute = require('is-absolute'),
    Q      = require('q'),
    pkg    = require('../package.json'),

    defaults = {
        dir: '.' + pkg.name + '/storage',
        stringify: JSON.stringify,
        parse: JSON.parse,
        encoding: 'utf8',
        logging: false,
        continuous: true,
        interval: false,
        expiredInterval: 2 * 60 * 1000, /* every 2 minutes */
        forgiveParseErrors: false,
        ttl: false
    },

    defaultTTL = 24 * 60 * 60 * 1000 /* if ttl is truthy but it's not a number, use 24h as default */,

    isNumber = function(n) {
        return !isNaN(parseFloat(n)) && isFinite(n);
    },

    isFunction = function(fn) {
        return typeof fn === 'function';
    },

    noop = function() {},

    md5 = function (data) {
        return crypto.createHash('md5').update(data).digest("hex");
    },

    isValidStorageFileContent = function (content) {
        return content && content.key;
    };

var LocalStorage = function (userOptions) {
    if(!(this instanceof LocalStorage)) {
        return new LocalStorage(userOptions);
    }
    this.data = {};
    this.changes = {};
    this.setOptions(userOptions);

    // we don't call init in the constructor because we can only do so for the initSync
    // for init async, it returns a promise, and in order to maintain that API, we cannot return the promise in the constructor
    // so init must be called, separately, on the instance of new LocalStorage();
};

LocalStorage.prototype = {

    setOptions: function (userOptions) {
        var options = {};

        if (!userOptions) {
            options = defaults;
        } else {
            for (var key in defaults) {
                if (userOptions.hasOwnProperty(key)) {
                    options[key] = userOptions[key];
                } else {
                    options[key] = defaults[key];
                }
            }

            options.dir = this.resolveDir(options.dir);
            options.ttl = options.ttl ? isNumber(options.ttl) && options.ttl > 0 ? options.ttl : defaultTTL : false;
        }

        // Check to see if we received an external logging function
        if (isFunction(options.logging)) {
            // Overwrite log function with external logging function
            this.log = options.logging;
            options.logging = true;
        }

        this.options = options;
    },

    init: function (userOptions, callback) {
        if (isFunction(userOptions)) {
            callback = userOptions;
            userOptions = null;
        }
        if (userOptions) {
            this.setOptions(userOptions);
        }
        callback = isFunction(callback) ? callback : noop;

        var deferred = Q.defer();
        var deferreds = [];

        var options = this.options;

        var result = {dir: options.dir};
        deferreds.push(this.parseStorageDir());

        //start persisting
        if (options.interval && options.interval > 0) {
            this.startPersistInterval(this.persist.bind(this));
        }

        if (options.expiredInterval) {
            this.startExpiredKeysInterval();
        }

        Q.all(deferreds).then(
            function() {
                deferred.resolve(result);
                callback(null, result);
            },
            function(err) {
                deferred.reject(err);
                callback(err);
            });

        return deferred.promise;
    },

    initSync: function (userOptions) {
        if (userOptions) {
            this.setOptions(userOptions);
        }

        var options = this.options;

        if (options.logging) {
            this.log("options:");
            this.log(this.stringify(options));
        }

        this.parseStorageDirSync();
        if (options.expiredInterval) {
            this.startExpiredKeysInterval();
        }
        //start synchronous persisting,
        if (options.interval && options.interval > 0) {
            this._persistInterval = setInterval(this.persistSync.bind(this), options.interval);
        }
    },

    keys: function () {
        return Object.keys(this.data);
    },

    length: function () {
        return this.keys().length;
    },

    forEach: function(callback) {
        return this.keys().forEach(function(key) {
            callback(key, this.getDataValue(key));
        }.bind(this));
    },

    values: function() {
        return this.keys().map(function(key) {
            return this.getDataValue(key);
        }.bind(this));
    },

    valuesWithKeyMatch: function(match) {
        match = match || /.*/;

        var filter = match instanceof RegExp ?
            function(key) {
                return match.test(key);
            } :
            function(key) {
                return key.indexOf(match) !== -1;
            };

        var values = [];
        this.keys().forEach(function(key) {
            if (filter(key)) {
                values.push(this.getDataValue(key));
            }
        }.bind(this));

        return values;
    },

    set: function (key, value, options, callback) {
        return this.setItem(key, value, options, callback);
    },

    setItem: function (key, dataValue, options, callback) {
        if (typeof options == 'function') {
            callback = options;
            options = null;
        }
        options = options || {};
        callback = isFunction(callback) ? callback : noop;

        var deferred = Q.defer();
        var deferreds = [];

        var value = this.copy(dataValue);
        // ttl is different that the other options because we can pass a different for each setItem, as well as have a default one.
        var ttl = this.calcTTL(options.ttl);
        this.data[key] = {value: value, ttl: ttl};

        var instanceOptions = this.options;
        var result = {key: key, value: value, ttl: ttl, queued: !!instanceOptions.interval, manual: !instanceOptions.interval && !instanceOptions.continuous};

        var onSuccess = function () {
            callback(null, result);
            deferred.resolve(result);
        };

        var onError = function (err) {
            callback(err);
            deferred.reject(err);
        };

        if (instanceOptions.logging) {
            this.log("set (" + key + ": " + this.stringify(value) + ")");
        }

        if (instanceOptions.interval || !instanceOptions.continuous) {
            this.changes[key] = {onError: onError};
            process.nextTick(onSuccess);
        } else {
            deferreds.push(this.persistKey(key));

            Q.all(deferreds).then(
                function(result) {
                    deferred.resolve(result);
                    callback(null, result);
                }.bind(this),
                function(err) {
                    deferred.reject(err);
                    callback(err);
                });
        }

        return deferred.promise;
    },

    setItemSync: function (key, dataValue, options) {
        options = options || {};
        var ttl = this.calcTTL(options.ttl);
        var value = this.copy(dataValue);
        this.data[key] = {key: key, value: value, ttl: ttl};
        this.persistKeySync(key);
        if (this.options.logging) {
            this.log("set (" + key + ": " + this.stringify(value) + ")");
        }
    },

    get: function (key, callback) {
        return this.getItem(key, callback);
    },

    getItem: function (key, callback) {
        callback = isFunction(callback) ? callback : noop;
        var deferred = Q.defer();

        if (this.isExpired(key)) {
            this.log(key + ' has expired');
            if (this.options.interval || !this.options.continuous) {
                callback(null, null);
                return deferred.resolve(null);
            }
            return this.removeItem(key).then(function() {
                callback(null, null);
                return null;
            }, callback);
        } else {
            var value = this.getDataValue(key);
            callback(null, value);
            deferred.resolve(value);
        }
        return deferred.promise;
    },

    getItemSync: function (key) {
        if (this.isExpired(key)) {
            this.removeItemSync(key);
        } else {
            return this.getDataValue(key);
        }
    },

    getDataValue: function (key) {
        return this.data[key] ? this.copy(this.data[key].value) : undefined;
    },

    del: function (key, callback) {
        return this.removeItem(key, callback);
    },

    rm: function (key, callback) {
        return this.removeItem(key, callback);
    },

    removeItem: function (key, callback) {
        callback = isFunction(callback) ? callback : noop;

        var deferred = Q.defer();
        var deferreds = [];

        deferreds.push(this.removePersistedKey(key));

        Q.all(deferreds).then(
            function() {
                var value = this.getDataValue(key);
                delete this.data[key];
                this.log('removed: ' + key);
                callback(null, value);
                deferred.resolve(value);
            }.bind(this),
            function(err) {
                callback(err);
                deferred.reject(err);
            }
        );
        return deferred.promise;
    },

    removeItemSync: function (key) {
        var value = this.getDataValue(key);
        this.removePersistedKeySync(key);
        delete this.data[key];
        this.log('removed: ' + key);
        return value;
    },

    removeExpiredItems: function (callback) {
        callback = isFunction(callback) ? callback : noop;
        var deferred = Q.defer();
        var deferreds = [];

        var keys = this.keys();
        for (var i = 0; i < keys.length; i++) {
            if (this.isExpired(keys[i])) {
                deferreds.push(this.removeItem(keys[i]));
            }
        }
        Q.all(deferreds).then(
            function() {
                deferred.resolve();
                callback();
            },
            function(err) {
                deferred.reject(err);
                callback(err);
            });

        return deferred.promise;

    },

    clear: function (callback) {
        callback = isFunction(callback) ? callback : noop;

        var deferred = Q.defer();
        var deferreds = [];

        var keys = this.keys();
        for (var i = 0; i < keys.length; i++) {
            deferreds.push(this.removePersistedKey(keys[i]));
        }

        Q.all(deferreds).then(
            function() {
                this.data = {};
                this.changes = {};
                deferred.resolve();
                callback();
            }.bind(this),
            function(err) {
                deferred.reject(err);
                callback(err);
            });

        return deferred.promise;
    },

    clearSync: function () {
        var keys = this.keys(true);
        for (var i = 0; i < keys.length; i++) {
            this.removePersistedKeySync(keys[i]);
        }
        this.data = {};
        this.changes = {};
    },

    persist: function (callback) {
        callback = isFunction(callback) ? callback : noop;

        var deferred = Q.defer();
        var result;
        var deferreds = [];

        for (var key in this.data) {
            if (this.changes[key]) {
                deferreds.push(this.persistKey(key));
            }
        }

        Q.all(deferreds).then(
            function(result) {
                deferred.resolve(result);
                callback(null, result);
                this.log('persist done');
            }.bind(this),
            function(err) {
                deferred.reject(result);
                callback(err);
            });

        return deferred.promise;
    },

    persistSync: function () {
        for (var key in this.data) {
            if (this.changes[key]) {
                this.persistKeySync(key);
            }
        }
        this.log('persistSync done');
    },

    /*
     * This function triggers a key within the database to persist asynchronously.
     */
    persistKey: function (key, callback) {
        callback = isFunction(callback) ? callback : noop;

        var self = this;
        var options = this.options;
        var file = path.join(options.dir, md5(key));

        var deferred = Q.defer();
        var output = {key: key, value: this.data[key] && this.data[key].value, ttl: this.data[key] && this.data[key].ttl};

        fs.writeFile(file, this.stringify(output), options.encoding, function(err) {
            if (err) {
                self.changes[key] && self.changes[key].onError && self.changes[key].onError(err);
                deferred.reject(err);
                return callback(err);
            }

            self.changes[key] && self.changes[key].onSuccess && self.changes[key].onSuccess();
            delete self.changes[key];
            deferred.resolve(output);
            callback(null, output);
            self.log("wrote: " + key);
        });

        return deferred.promise;
    },

    persistKeySync: function (key) {
        var options = this.options;
        var file = path.join(options.dir, md5(key));

        var output = {key: key, value: this.data[key] && this.data[key].value, ttl: this.data[key] && this.data[key].ttl};
        try {
            fs.writeFileSync(file, this.stringify(output));
            this.changes[key] && this.changes[key].onSuccess && this.changes[key].onSuccess();
        } catch (err) {
            this.changes[key] && this.changes[key].onError && this.changes[key].onError(err);
            throw err;
        }
        delete this.changes[key];
        this.log("wrote: " + key);
    },

    removePersistedKey: function (key, callback) {
        callback = isFunction(callback) ? callback : noop;

        var options = this.options;
        var deferred = Q.defer();
        var result;

        //check to see if key has been persisted
        var file = path.join(options.dir, md5(key));
        fs.exists(file, function (exists) {
            if (exists) {
                fs.unlink(file, function (err) {
                    result = {key: key, removed: !err, existed: exists};
                    if (err && err.code != 'ENOENT') { /* Only throw the error if the error is something else */
                        deferred.reject(err);
                        return callback(err);
                    }
                    err && this.log('Failed to remove file:' + file + ' because it doesn\'t exist anymore.');
                    deferred.resolve(result);
                    callback(null, result);
                }.bind(this));
            } else {
                result = {key: key, removed: false, existed: exists};
                deferred.resolve(result);
                callback(null, result);
            }
        }.bind(this));

        return deferred.promise;
    },

    removePersistedKeySync: function(key) {
        var options = this.options;
        var file = path.join(options.dir, md5(key));
        if (fs.existsSync(file)) {
            try {
                fs.unlinkSync(file);
            } catch (err) {
                if (err.code != 'ENOENT') { /* Only throw the error if the error is something else */
                    throw err;
                }
                this.log('Failed to remove file:' + file + ' because it doesn\'t exist anymore.');
            }
            return {key: key, removed: true, existed: true};
        }
        return {key: key, removed: false, existed: false};
    },
    
    stringify: function (obj) {
        return this.options.stringify(obj);    
    },

    parse: function(str){
        try {
            return this.options.parse(str);
        } catch(e) {
            this.log("parse error: ", this.stringify(e));
            return undefined;
        }
    },

    copy: function (value) {
        // don't copy literals since they're passed by value
        if (typeof value != 'object') {
            return value;
        }
        return this.parse(this.stringify(value));
    },

    parseStorageDir: function(callback) {
        callback = isFunction(callback) ? callback : noop;

        var deferred = Q.defer();
        var deferreds = [];

        var dir = this.options.dir;
        var self = this;
        var result = {dir: dir};

        //check to see if dir is present
        fs.exists(dir, function (exists) {
            if (exists) {
                //load data
                fs.readdir(dir, function (err, arr) {
                    if (err) {
                        deferred.reject(err);
                        callback(err);
                    }

                    for (var i in arr) {
                        var currentFile = arr[i];
                        if (currentFile[0] !== '.') {
                            deferreds.push(self.parseFile(currentFile));
                        }
                    }

                    Q.all(deferreds).then(
                        function() {
                            deferred.resolve(result);
                            callback(null, result);
                        },
                        function(err) {
                            deferred.reject(err);
                            callback(err);
                        });
                });
            } else {
                //create the directory
                mkdirp(dir, function (err) {
                    if (err) {
                        console.error(err);
                        deferred.reject(err);
                        callback(err);
                    } else {
                        self.log('created ' + dir);
                        deferred.resolve(result);
                        callback(null, result);
                    }
                });
            }
        });

        return deferred.promise;
    },

    parseStorageDirSync: function() {
        var dir = this.options.dir;
        var exists = fs.existsSync(dir);

        if (exists) { //load data
            var arr = fs.readdirSync(dir);
            for (var i = 0; i < arr.length; i++) {
                var currentFile = arr[i];
                if (arr[i] && currentFile[0] !== '.') {
                    this.parseFileSync(currentFile);
                }
            }
        } else { //create the directory
            mkdirp.sync(dir);
        }
    },

    parseFile: function (filename, callback) {
        callback = isFunction(callback) ? callback : noop;

        var deferred = Q.defer();
        var self = this;
        var options = this.options;
        var dir = this.options.dir;
        var file = path.join(dir, filename);

        var error = function (err) {
            deferred.reject(err);
            return callback(err);
        };

        var done = function (input) {
            deferred.resolve(input);
            callback(null, input);
        };

        fs.readFile(file, options.encoding, function (err, text) {
            if (err) {
                return error(err);
            }
            var input = self.parse(text);
            if (!isValidStorageFileContent(input)) {
                return options.forgiveParseErrors ? done() : error(new Error('[PARSE-ERROR] ' + file + ' does not look like a valid storage file!'));
            }
            self.data[input.key] = input;
            self.log("loaded: " + dir + "/" + input.key);
            done(input);
        });

        return deferred.promise;
    },

    parseFileSync: function(filename) {
        var dir = this.options.dir;
        var file = path.join(dir, filename);
        var input = this.parse(fs.readFileSync(file, this.options.encoding));
        if (!isValidStorageFileContent(input)) {
            if (this.options.forgiveParseErrors) {
                return;
            }
            throw Error('[PARSE-ERROR] ' + file + ' does not look like a valid storage file!');
        }
        this.data[input.key] = input;
        this.log("loaded: " + dir + "/" + input.key);
        return this.data[input.key];
    },

    calcTTL: function (ttl) {
        // only check for undefined, if null was passed in setItem then we probably didn't want to use the this.options.ttl
        if (typeof ttl == 'undefined') {
            ttl = this.options.ttl;
        } else {
            ttl = ttl ? isNumber(ttl) && ttl > 0 ? ttl : defaultTTL : false;
        }
        return ttl ? new Date().getTime() + ttl : undefined;
    },

    isExpired: function (key) {
        return this.data[key] && this.data[key].ttl && this.data[key].ttl < (new Date()).getTime();
    },

    resolveDir: function(dir) {
        dir = path.normalize(dir);
        if (isAbsolute(dir)) {
            return dir;
        }
        return path.join(process.cwd(), dir);
    },

    startPersistInterval: function (persistFunction) {
        this.stopPersistInterval();
        this._persistInterval = setInterval(persistFunction || this.persist.bind(this), this.options.interval);
        this._persistInterval.unref && this._persistInterval.unref();
    },

    stopPersistInterval: function () {
        clearInterval(this._persistInterval);
    },

    startExpiredKeysInterval: function () {
        this.stopExpiredKeysInterval();
        this._expiredKeysInterval = setInterval(this.removeExpiredItems.bind(this), this.options.expiredInterval);
        this._expiredKeysInterval.unref && this._expiredKeysInterval.unref();
    },

    stopExpiredKeysInterval: function () {
        clearInterval(this._expiredKeysInterval);
    },

    log: function () {
        this.options && this.options.logging && console.log.apply(console, arguments);
    },

    md5: md5
};

module.exports = LocalStorage;




© 2015 - 2025 Weber Informatics LLC | Privacy Policy