Source: SimpleNodeDb.js

/**
 * @class SimpleNodeDb
 *
 * @author: darryl.west@raincitysoftware.com
 * @created: 5/24/14 1:04 PM
 */
var levelup = require('levelup' ),
    uuid = require('node-uuid' ),
    fs = require('fs' );

var SimpleNodeDb = function(options) {
    'use strict';

    var sdb = this,
        log,
        db,
        memory = false;

    (function() {
        if (options) {
            log = options.log;
        }

        if (!log) {
            log = require('simple-node-logger' ).createLogger();
            log.setLevel( 'warn' );
        }

        if (!options) {
            log.info('create memory-only db');
            db = levelup({ db:require('memdown')} );
            memory = true;
        } else {
            if (typeof options === 'string') {

                log.info('create the database: ', options);
                db = levelup( options );
            } else {
                if (options.path) {
                    log.info('create the database: ', options.path);
                    db = levelup( options.path );
                }

                // TODO check for other options...
            }
        }
    })();

    /**
     * @returns true if db is in-memory, false if file backed
     */
    this.isInMemory = function() {
        return memory;
    };

    /**
     * query for a list of matching rows using rowCallback to evaluate key or model criteria.  When a match
     * is detected the model (parsed from value) is returned.
     *
     * @param params - limit, offset
     * @param rowCallback(key, value) (value is the json string; it must be parsed to create the model
     * @param completeCallback(err, list)
     */
    this.query = function(params, rowCallback, completeCallback) {
        log.info('query the database with params: ', params);

        var error,
            list = [],
            stream = db.createReadStream();

        stream.on('data', function(data) {
            var row = rowCallback( data.key, data.value );
            if (row) {
                list.push( row );
            }
        });

        stream.on('error', function(err) {
            log.error('error in query stream: ', err.message);
            error = err;
        });

        stream.on('end', function() {
            completeCallback( error, list );
        });
    };

    /**
     * find the single model by key; returns the parsed model if found, error if not found (err.isNotFound)
     *
     * @param key - the domain key for a specified model
     * @param callback(err, model)
     */
    this.find = function(key, callback) {
        log.info('find the record with this key: ', key);

        callback(new Error('query not implemented yet'));
    };

    /**
     * insert a data model; set the dateCreated & lastUpdated to now and set version number to zero.
     *
     * @param key - the domain specific key
     * @param model - a data model
     * @param callback(err, model)
     */
    this.insert = function(key, model, callback) {
        var jmodel,
            insertCallback;

        if (typeof model !== 'object') {
            log.error('insert model must be an object');
            return callback(new Error('model must be an object'));
        }

        model.dateCreated = model.lastUpdated = new Date();
        model.version = 0;

        jmodel = JSON.stringify( model );
        log.info('insert the model: ', jmodel);

        insertCallback = function(err) {
            if (err) {
                log.error( 'Error inserting model: ', jmodel, ', with key: ', key, ', message: ', err.message );
            }

            return callback( err, model );
        };

        db.put( key, jmodel, insertCallback );
    };

    /**
     * update the current model; bump the version number and update the lastUpdated timestamp
     *
     * @param key
     * @param model
     * @param callback(err, model)
     */
    this.update = function(key, model, callback) {
        var jmodel,
            updateCallback;

        if (typeof model !== 'object') {
            log.error('insert model must be an object');
            return callback(new Error('model must be an object'));
        }

        model.lastUpdated = new Date();
        model.version = model.version + 1;

        jmodel = JSON.stringify( model );
        log.info('update the model: ', jmodel);

        updateCallback = function(err) {
            if (err) {
                log.error( 'Error inserting model: ', jmodel, ', with key: ', key, ', message: ', err.message );
            }

            return callback( err, model );
        };

        db.put( key, jmodel, updateCallback );
    };

    /**
     * delete the model by key;
     *
     * @param key
     * @param callback(err)
     */
    this.delete = function(key, callback) {
        log.info('delete the model: ', key);

        db.del( key, callback );
    };

    /**
     * backup/unload the current database to a key/value file.
     *
     * @param filename - full path the the backup file
     * @param callback(err, rowCount)
     */
    this.backup = function(filename, callback) {
        log.info('backup the database to ', filename);

        var opts = {
                flags:'w',
                encoding:'utf-8',
                mode:parseInt('0644', 8) // parse the octal
            },
            count = 0,
            error,
            writer = fs.createWriteStream( filename, opts ),
            reader = db.createReadStream();

        writer.on('finish', function() {
            callback( error, count );
        });

        reader.on('data', function(data) {
            writer.write( data.key );
            writer.write( ',' );
            writer.write( data.value );
            writer.write( '\n' );

            count++;
        });

        reader.on('error', function(err) {
            log.error( 'read error: ', err.message );
            writer.end();
        });

        reader.on('end', function() {
            writer.end();
        });
    };

    /**
     * restore the backup file.  the restore process only adds/updates values to the database so multiple
     * files may be restored without affecting other non-related database rows.
     *
     * @param filename - full path to the restore file
     * @param callback(err, rowsRestored)
     */
    this.restore = function(filename, callback) {
        log.info('restore database from ', filename);
        var readline = require('readline' ),
            outstream = new require('stream' ),
            opts = {
                flags:'r',
                encoding:'utf-8',
                mode:parseInt('0644', 8), // parse the octal
                autoClose:true,
                fd: null
            },
            batch = [],
            error,
            instream = fs.createReadStream( filename, opts ),
            lineReader;

        var processLine = function(line) {
            var idx,
                key,
                value,
                model;

            if (line && line.indexOf(',') > 1) {
                idx = line.indexOf(',');
                key = line.substr(0, idx );
                value = line.substr( idx + 1 );
                if (key && value) {
                    try {
                        model = JSON.parse( value );
                        batch.push({ type:'put', key:key, value:value });
                    } catch (e) {
                        log.error('PARSE ERROR! line:', line);
                        log.error( e.message );
                        error = e;
                    }
                }
            }
        };

        lineReader = readline.createInterface( instream, outstream );
        lineReader.on('line', processLine );

        instream.on('close', function() {
            if (error) return callback( error );

            if (batch.length > 0) {
                log.info('insert the batch, rows: ', batch.length);

                db.batch( batch, function(err) {
                    callback( err, batch.length );
                });
            }
        });
    };

    /**
     * calculate the database stats and verify that each row parses without error.
     *
     * @param callback(err, stats)
     */
    this.stats = function(callback) {
        log.info('calculate stats');

        var domains = {},
            errors = [],
            count = 0,
            stream = db.createReadStream();

        stream.on('data', function(data) {
            var key = data.key,
                value = data.value,
                domain = data.key.split(':')[0];

            count++;

            if (domains.hasOwnProperty( domain )) {
                domains[ domain ] = domains[ domain ] + 1;
            } else {
                domains[ domain ] = 1;
            }

            try {
                var obj = JSON.parse( value );
            } catch(e) {
                log.error( e.message );
                log.error('error parsing value: ', value);
                errors.push( { key:key, value:value, message: e.messasge } );
            }
        });

        stream.on('error', function(err) {
            log.error('error in query stream: ', err.message);
            errors.push( err.message );
        });

        stream.on('end', function(err) {
            var stats = {
                rowcount:count,
                domains:domains,
                errors:errors
            };

            callback( err, stats );
        });

    };

    this.replicate = function(replicatePath, callback) {
        log.info('create a replicate of the current database in: ', replicatePath);

        callback(new Error('query not implemented yet'));
    };

    /**
     * open the database; should provide a callback to give the db time to open
     * @param callback(err)
     */
    this.open = function(callback) {
        if (db.isOpen()) {
            log.warn('attempt to open an opened database, request ignored...');
            callback();
        } else {
            log.info('open/reopen the database...');
            db.open(callback);
        }
    };

    /**
     * close the current database
     *
     * @param callback(err)
     */
    this.close = function(callback) {
        if (db.isClosed()) {
            log.warn('attempt to close a closed database, request ignored...');
            callback();
        } else {
            log.info('close the database...');
            db.close(callback);
        }
    };

    /**
     * create a unique id using uuid without the dashes
     * @returns unique id
     */
    this.createModelId = function() {
        return uuid.v4().replace(/-/g, '');
    };

    /**
     * create a domain key using the domain name + ':' + a generated uuid
     *
     * @param domain - the name of the domain, e.g., user, order, etc.
     * @returns the new id/key
     */
    this.createDomainKey = function(domain, id) {
        if (!id) {
            id = uuid.v4().replace(/-/g, '');
        }

        if (domain) {
            id = domain + ':' + id;
        }

        return id;
    };

    /**
     * @returns an object that exposes the private attributes of this instance
     */
    this.__protected = function() {
        return {
            log:log,
            levelDb:db
        };
    };
};

module.exports = SimpleNodeDb;