Source: SeLite/sqlite-connection-manager/src/chrome/content/SqliteConnectionManager.js

/*  Copyright 2011, 2012, 2013, 2014 Peter Kehl
    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Lesser General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Lesser General Public License for more details.

    You should have received a copy of the GNU Lesser General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
// Based on
// https://developer.mozilla.org/en/Storage/Performance#Keeping_the_cache_between_transactions
// https://bugzilla.mozilla.org/show_bug.cgi?format=multiple&id=380345
// https://developer.mozilla.org/en/How_to_Build_an_XPCOM_Component_in_Javascript#Creating_the_Component
// https://developer.mozilla.org/en/XPCOM/XPCOM_changes_in_Gecko_2.0

// Don't use SQLite virtual tables neither any full-text indexes in cache mode. See
// https://developer.mozilla.org/en/XPCOM_Interface_Reference/mozIStorageService#openDatabase()

var runningAsComponent= (typeof window==='undefined' || window && window.location && window.location.protocol=='chrome:'); // When set to false, this can be loaded via <script src="file://..."> rather than via Components.utils.import(). Used for limited debugging only. Can't use <script src="chrome://...">
if( runningAsComponent ) {
    Components.utils.import("resource://gre/modules/Services.jsm");
    Components.utils.import("resource://gre/modules/FileUtils.jsm");
    var console= Components.utils.import("resource://gre/modules/Console.jsm", {}).console;
}

function SQLiteConnectionParameters() {}

SQLiteConnectionParameters.prototype= {
  /** @var string filename File name of your SQLite DB file, either
   *  - as it is under your current Firefox profile, no path before it; include any extension; or
   *  - including the full path
      The extension doesn't really matter (as far as it matches the file's extension), but don't use '.sdb' extension - see https://developer.mozilla.org/en/Storage#Opening_a_connection
    */
    fileName: null,
    /** @var bool lockExclusive Whether to use PRAGMA locking_mode=EXCLUSIVE.
      By default, SQLite uses PRAGMA locking_mode=normal. Using EXCLUSIVE speeds things up.
      See http://www.sqlite.org/pragma.html#pragma_locking_mode and https://bugzilla.mozilla.org/show_bug.cgi?format=multiple&id=380345
      Mozilla's wrapper around SQLite may decide to ignore/adjust these settings.
    */
    lockExclusive: false,
    /** @var cacheRatio - Default cache will be set to cacheRatio*current-DB-size, subject to limits by cacheMin and cacheMax
     */
    cacheRatio: null,
    /**  @var int cacheMin Minimal cache size, in DB pages. See http://www.sqlite.org/pragma.html -> pragma cache_size, page_count, page_size
    */
    cacheMin: null,
    /** @var int cacheMax Maximum cache size, in DB pages
     */
    cacheMax: null,
    // preloadCache has effect only when the connection is created. If re-using an existing connection, preloadCache is ignored.
    // @TODO optional parameter: a narrowed down list of tables to pre-load from. The same on Db object, but then append table prefixes.
    preloadCache: false,
    // Used to report any (unlikely) errors when pre-loading tables to the cache. That is done asynchronously, therefore throwing an error
    // from there doesn't get reported.
    // If errorHandler is set, it must be a function that takes one string parameter - error message. You can just use 'alert' for this - without apostrophes.
    errorHandler: null,
    // I'm not implementing neither using clone() to make protective copies of these instances.
    // That could prevent client's stupidity (e.g. the client re-using these instances for
    // connections using different DB files). But it couldn't prevent insecure/malicious
    // - since the client can call Mozilla API directly, anyway.
    
  /** This opens a connection and returns it; if it was open already
   *  then it returns the existing connection.
   *  There may be only 1 type of connection - cached or uncached - per fileName
   *  at any time. If you call this function twice with the same filename and
   *  different boolean values for useCache, you must call close() in between
   *  - otherwise the 2nd call fails.
   *  @return SQLite connection object
   *  @throw whatever bad happens
   **/
  connect: function connect() {
      var info= locateConnectionInfo( this, 'connect' );
      if( info ) {
          return info.connection;
      }
      var info= new SQLiteConnectionInfo( this );
      info.open();
      SQLiteConnectionInfo.connectionInfos.push( info );
      return info.connection;
  },
          
  /** This closes the connection.
   *  @param mixed fileNameOrConnectionOrParameters
   *  - string: exact (case-sensitive equal) as 'fileName' field of 'parameters' parameter that has been passed to connect(), or
   *  - object: exact as (or equal to, but of the same class as) 'parameters' parameter that has been passed to connect(), or
   *  - DB connection itself.
   *  @param bool synchronous Whether to close it down synchronously; otherwise it's closed down asynchronously (default).
   *  If you've executed any asynchronous statements, then it must be false.
   *  See https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/mozIStorageConnection#close%28%29.
   *  Note that when true, I regularly got NS_ERROR_STORAGE_BUSY: Component returned failure code: 0x80630001 (NS_ERROR_STORAGE_BUSY) [mozIStorageConnection.close], even though I have only used synchronous statements! So, it's safer to pass synchronous=true.
   *  @param {function} [callback] Function to call after closed (regardless whether synchronous or asynchronous close)
   *  @return void
   *  @throw if fileNameOrConnection doesn't match any open connection object
   *  neither its parameters neither any of their filenames, or on underlying failure
   **/
  close: function close( synchronous, callback ) {
      var info= locateConnectionInfo( this, 'close' );
      if( info ) {
          info.close( synchronous, callback );
      }
      else {
          throw new Error( "SQLiteConnectionParameters.close() couldn't find an open connection to " +this.fileName );
      }
  },

  beingClosedDown: function beingClosedDown() {
      var info= locateConnectionInfo( this, 'beingClosedDown' );
      return info && info.beingClosedDown;
  },
};

/** This opens the file and a connection (and a cache-keeping connection, if needed).
 *  When this is called, it's assumed that there's no existing connection for given fileName.
 **/
function SQLiteConnectionInfo( parameters ) {
    if( !(parameters instanceof SQLiteConnectionParameters) ) {
        throw Error( 'SQLiteConnectionInfo() expects parameter "parameters" to be of class SQLiteConnectionParameters.' );
    }
    this.parameters= parameters;
}

SQLiteConnectionInfo.prototype= {
    parameters: null, //SQLiteConnectionParameters instance
    connection: null,
    beingClosedDown: false
};

/** @var 'static' field, an array of SQLiteConnectionInfo instances for which there are SQLite connections currently open or being shut down
 * */
SQLiteConnectionInfo.connectionInfos= [];

function preloadCacheTable( connection, tableNames, tableIndex=0, errorHandler ) {
    if( tableIndex==tableNames.length ) {
        connection.asyncClose(); //@TODO why?
        return;
    }
    var statement= connection.createAsyncStatement( 'SELECT * FROM ' +tableNames[tableIndex] );
    statement.executeAsync( {
        handleResult: function handleResult(aResultSet) {
            // This gets called only if there was data returned from DB
            // This may be called several times per same statement!
            while( aResultSet.getNextRow() );
        },
        handleError: function handleError(aError) {
            if( errorHandler ) {
                errorHandler( "Couldn't pre-load cache for table " +tableNames[tableIndex]+ ': ' +aError );
            }
        },
        handleCompletion: function handleCompletion(aReason) {
            // Chain - preload the rest of the tables, recursively. This gets called whether there was any data returned or not
            preloadCacheTable( connection, tableNames, tableIndex+1, errorHandler );
        }
    } );
}

function preloadCache( connection, errorHandler ) {
    if( true ) return;//@TODO
    try {
        connection= connection.clone( true );
        var stmt= connection.createStatement( "SELECT name FROM SQLITE_MASTER where type='table'" );
        var tableNames= [];
        try {
            while( stmt.executeStep() ) {
                tableNames.push( stmt.row.name );
            }
        }
        finally {
            stmt.finalize();
        }
    }
    catch( error ) {
        throw new Error( "Couldn't fetch a list of tables for pre-loading the cache: " +error );
    }
    preloadCacheTable( connection, tableNames, 0, errorHandler );
}

/** This opens the connection and sets it as required. If successfull, it
 *  assigns the connection to this.connection; otherwise it returns without setting it.
 *  @return void
 *  @throws Error on failure */
SQLiteConnectionInfo.prototype.open= function open() {
    console.log( 'SQLiteConnectionInfo.open(): Trying to open ' +this.parameters.fileName );
    var file;
    try {
        file= FileUtils.getFile( "ProfD", [this.parameters.fileName] );
    }
    catch( error ) {
        file= new FileUtils.File( this.parameters.fileName );
    }
    if( !file.exists() ) {
        throw 'DB file ' +this.parameters.fileName+ " doesn't exist.";
    }
    var connection;
    connection= Services.storage.openDatabase( file );
    // There's no need neither a way to 'close' file. See https://developer.mozilla.org/en/XPCOM_Interface_Reference/nsIFile
    if( !connection.connectionReady ) {
        throw "Created the connection, but it wasn't ready.";
    }
    if( this.parameters.lockExclusive ) {
        connection.executeSimpleSQL( "PRAGMA locking_mode=EXCLUSIVE" );
    }
    if( this.parameters.cacheRatio!=null || this.parameters.cacheMin!=null || this.parameters.cacheMax!=null ) {
        
        var cacheSize= null; // By the end of this block, cacheSize will be the result cache size, in DB pages
        if( this.parameters.cacheRatio!=null ) {
            var stmt= connection.createStatement( "PRAGMA page_count" );
            var dbSize;
            try {
                if( !stmt.executeStep() ) {
                    throw "Couldn't get PRAGMA page_count";
                }
                // typeof stmt.row.page_count is 'number' - very good
                dbSize= stmt.row.page_count; // in DB pages
            }
            finally {
                stmt.finalize();
            }
            cacheSize= Math.round( dbSize*this.parameters.cacheRatio );
        }
        else { // get the default page size
            var stmt= connection.createStatement( "PRAGMA cache_size" );
            try {
                if( !stmt.executeStep() ) {
                    throw "Couldn't get PRAGMA cache_size";
                }
                var msg= '';
                for( var field in stmt.row ) {
                    msg+= field+ ': '+ stmt.row[field]+ '; ';
                }
                cacheSize= stmt.row.cache_size;
            }
            finally {
                stmt.finalize();
            }
        }

        if( this.parameters.cacheMin!=null || this.parameters.cacheMax!=null ) {
            var stmt= connection.createStatement( "PRAGMA page_size" );
            try {
                if( !stmt.executeStep() ) {
                    throw "Couldn't get PRAGMA page_size";
                }
                var pageSize= stmt.row.page_size; // in bytes
            }
            finally {
                stmt.finalize();
            }

            // Let's get min & max values in DB pages
            var MB= 1024*1024;
            if( this.parameters.cacheMin!=null ) {
                var cacheMin= Math.round( this.parameters.cacheMin*MB/pageSize );
                cacheSize= Math.max( cacheSize, cacheMin );
            }
            if( this.parameters.cacheMax!=null ) {
                var cacheMax= Math.round( this.parameters.cacheMax*MB/pageSize );
                cacheSize= Math.min( cacheSize, cacheMax );
            }
        }
        connection.executeSimpleSQL( "PRAGMA cache_size=" +cacheSize );
    }
    this.connection= connection;
    if( this.parameters.preloadCache ) {
        preloadCache( connection, this.parameters.errorHandler );
    }
};

/** This closes the connection asynchronously. It sets beingClosedDown=true
 * until the asynchronous close completes; then it removes this SQLiteConnectionInfo
 * instance from list of instances.
 * @param bool synchronous Whether to close it down synchronously; otherwise it's closed down asynchronously (default).
 *  @param {function} [callback] Function to call after closed (regardless whether synchronous or asynchronous close)
 * @return void
 * @throw on error (or if beingClosedDown was set already)
 * */
SQLiteConnectionInfo.prototype.close= function close( synchronous, callback ) {
    if( this.beingClosedDown ) {
        throw new Error( 'SQLiteConnectionInfo.close(): the connection is already being closed down.' );
    }
    var info= this;
    var completionHandler= {
        complete: function complete() {
            // remove itself from SQLiteConnectionInfo.connectionInfos
            for( var i=0; i<SQLiteConnectionInfo.connectionInfos.length; i++ ) {
                if( SQLiteConnectionInfo.connectionInfos[i]===info ) {
                    SQLiteConnectionInfo.connectionInfos.splice( i, 1 );
                }
            }
            this.beingClosedDown= false;
            this.connection= null;
            if( !synchronous ) {
                console.log( "SQLiteConnectionInfo.close() successfully closed asynchronously." );
            }
            if( callback ) {
                callback();
            }
        }
    };
    this.beingClosedDown= true;
    if( synchronous ) {
        this.connection.close();
        completionHandler.complete();
    }
    else {
        this.connection.asyncClose( completionHandler );
    }
};

/** Locate SQLiteConnectionInfo instance, if any. 'Private function'.
 *  @param mixed fileNameOrConnectionOrParameters Either instance of SQLiteConnectionParameters, or a full path+filename of an SQLite file
 *  @param string callerFunctionName Used to make the error messages nicer.
 *  @return {SQLiteConnectionInfo} instance, if matched; null if not matched but no error
 *  @throw Error if fileNameOrConnectionOrParameters is of incorrect type
 *  */
function locateConnectionInfo( fileNameOrConnectionOrParameters, callerFunctionName ) {
      for( var i=0; i<SQLiteConnectionInfo.connectionInfos.length; i++ ) {
          var info= SQLiteConnectionInfo.connectionInfos[i];

          if( typeof fileNameOrConnectionOrParameters=='object' ) {
              if( fileNameOrConnectionOrParameters instanceof SQLiteConnectionParameters ) {
                  if( info.parameters.fileName==fileNameOrConnectionOrParameters.fileName ) {
                      if( info.parameters.lockExclusive==fileNameOrConnectionOrParameters.lockExclusive ) {
                          return info;
                      }
                      throw new Error( 'SQLiteConnectionParameters.' +callerFunctionName+ '() called with an object parameter, '+
                        'whose fileName matched, but lockExclusive was different: ' +fileNameOrConnectionOrParameters.lockExclusive );
                  }
              }
              else {
                  if( info.connection==fileNameOrConnectionOrParameters ) {
                      return info;
                  }
              }
          }
          else
          if( typeof fileNameOrConnectionOrParameters=='string' ) {
              if( info.parametersfile.fileName==fileNameOrConnectionOrParameters ) {
                  return info;
              }
          }
          else {
              throw new Error( 'SQLiteConnectionParameters.' +callerFunctionName+ '() called with a parameter of unsupported type: ' +(typeof fileNameOrConnectionOrParameters) );
          }
      }
      return null;
}

var EXPORTED_SYMBOLS= ['SQLiteConnectionParameters', 'SQLiteConnectionInfo'];