/** * Mongo DB DAO for program server * * Copyright (C) 2010-2019 R-T Specialty, LLC. * * This file is part of the Liza Data Collection Framework. * * liza is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero 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 General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import { ServerDao, Callback } from "./ServerDao"; import { MongoCollection, MongoUpdate, MongoDb } from "mongodb"; import { PositiveInteger } from "../../numeric"; import { ServerSideQuote } from "../quote/ServerSideQuote"; import { QuoteId } from "../../document/Document"; import { WorksheetData } from "../rater/Rater"; const EventEmitter = require( 'events' ).EventEmitter; type ErrorCallback = ( err: NullableError ) => void; /** * Uses MongoDB as a data store */ export class MongoServerDao extends EventEmitter implements ServerDao { /** Collection used to store quotes */ readonly COLLECTION: string = 'quotes'; /** Sequence (auto-increment) collection */ readonly COLLECTION_SEQ: string = 'seq'; /** Sequence key for quote ids */ readonly SEQ_QUOTE_ID: string = 'quoteId'; /** Sequence quoteId default */ readonly SEQ_QUOTE_ID_DEFAULT: number = 200000; /** Whether the DAO is initialized and ready to be used */ private _ready: boolean = false; /** Collection to save data to */ private _collection?: MongoCollection | null; /** Collection to read sequences (auto-increments) from */ private _seqCollection?: MongoCollection | null; /** * Initializes DAO * * @param {Mongo.Db} db mongo database connection */ constructor( private readonly _db: MongoDb, private readonly _env: string, ) { super(); } /** * Initializes error events and attempts to connect to the database * * connectError event will be emitted on failure. * * @param Function callback function to call when connection is complete * (will not be called if connection fails) * * @return MongoServerDao self to allow for method chaining */ init( callback: () => void ): this { var dao = this; // map db error event (on connection error) to our connectError event this._db.on( 'error', function( err: Error ) { dao._ready = false; dao._collection = null; dao.emit( 'connectError', err ); }); this.connect( callback ); return this; } /** * Attempts to connect to the database * * connectError event will be emitted on failure. * * @param Function callback function to call when connection is complete * (will not be called if connection fails) * * @return MongoServerDao self to allow for method chaining */ connect( callback: () => void ): this { var dao = this; // attempt to connect to the database this._db.open( function( err: any, db: any ) { // if there was an error, don't bother with anything else if ( err ) { // in some circumstances, it may just be telling us that we're // already connected (even though the connection may have been // broken) if ( err.errno !== undefined ) { dao.emit( 'connectError', err ); return; } } var ready_count = 0; var check_ready = function() { if ( ++ready_count < 2 ) { return; } // we're ready to roll! dao._ready = true; dao.emit( 'ready' ); // connection was successful; call the callback if ( callback instanceof Function ) { callback.call( dao ); } } // quotes collection db.collection( dao.COLLECTION, function( _err: any, collection: MongoCollection, ) { // for some reason this gets called more than once if ( collection == null ) { return; } // initialize indexes collection.createIndex( [ ['id', 1] ], true, function( _err: NullableError, _index: { [P: string]: any, } ) { // mark the DAO as ready to be used dao._collection = collection; check_ready(); } ); } ); // seq collection db.collection( dao.COLLECTION_SEQ, function( err: Error, collection: MongoCollection, ) { if ( err ) { dao.emit( 'seqError', err ); return; } if ( collection == null ) { return; } dao._seqCollection = collection; // has the sequence we'll be referencing been initialized? collection.find( { _id: dao.SEQ_QUOTE_ID }, { limit: 1 }, function( err: NullableError, cursor ) { if ( err ) { dao._initQuoteIdSeq( check_ready ) return; } cursor.toArray( function( _err: Error, data: any[] ) { if ( data.length == 0 ) { dao._initQuoteIdSeq( check_ready ); return; } check_ready(); }); } ); } ); }); return this; } private _initQuoteIdSeq( callback: () => void ) { var dao = this; this._seqCollection!.insert( { _id: this.SEQ_QUOTE_ID, val: this.SEQ_QUOTE_ID_DEFAULT, }, function( err: NullableError, _docs: any ) { if ( err ) { dao.emit( 'seqError', err ); return; } dao.emit( 'seqInit', dao.SEQ_QUOTE_ID ); callback.call( dao ); } ); } /** * Saves a quote to the database * * A full save will include all metadata. This should not cause any * problems with race conditions for pending Data API calls on meta * fields because those results write to individual indexes and do not * rely on existing data. * * @param Quote quote the quote to save * @param Function success_callback function to call on success * @param Function failure_callback function to call if save fails * @param Object save_data quote data to save (optional) * @param Object push_data quote data to push (optional) */ saveQuote( quote: ServerSideQuote, success_callback: Callback, failure_callback: Callback, save_data?: any, push_data?: any, ): this { var dao = this; var meta: Record = {}; // if we're not ready, then we can't save the quote! if ( this._ready === false ) { this.emit( 'saveQuoteError', { message: 'Database server not ready' }, Error( 'Database not ready' ), quote ); failure_callback.call( this, quote ); return dao; } if ( save_data === undefined ) { save_data = { data: quote.getBucket().getData(), }; // full save will include all metadata meta = quote.getMetabucket().getData(); } var id = quote.getId(); // some data should always be saved because the quote will be created if // it does not yet exist save_data.id = id; save_data.env = this._env; save_data.pver = quote.getProgramVersion(); save_data.importDirty = 1; save_data.published = false; save_data.lastPremDate = quote.getLastPremiumDate(); save_data.initialRatedDate = quote.getRatedDate(); save_data.explicitLock = quote.getExplicitLockReason(); save_data.explicitLockStepId = quote.getExplicitLockStep(); save_data.importedInd = +quote.isImported(); save_data.boundInd = +quote.isBound(); save_data.lastUpdate = Math.round( ( new Date() ).getTime() / 1000 ); // meta will eventually take over for much of the above data meta.liza_timestamp_initial_rated = [ quote.getRatedDate() ]; // save the stack so we can track this call via the oplog save_data._stack = ( new Error() ).stack; // avoid wiping out other metadata (since this may not be a full set) Object.keys( meta ).forEach( key => save_data[ 'meta.' + key ] = meta[ key ] ); // do not push empty objects const document = ( !push_data || !Object.keys( push_data ).length ) ? { '$set': save_data } : { '$set': save_data, '$push': push_data }; // update the quote data if it already exists (same id), otherwise // insert it this._collection!.update( { id: id }, document, // create record if it does not yet exist { upsert: true }, // on complete function( err, _docs ) { // if an error occurred, then we cannot continue if ( err ) { dao.emit( 'saveQuoteError', err, quote ); // let the caller handle the error if ( failure_callback instanceof Function ) { failure_callback.call( dao, quote ); } return; } // successful if ( success_callback instanceof Function ) { success_callback.call( dao, quote ); } } ); return this; } /** * Merges quote data with the existing (rather than overwriting) * * @param {Quote} quote quote to save * @param {Object} data quote data * @param {Function} scallback successful callback * @param {Function} fcallback failure callback */ mergeData( quote: ServerSideQuote, data: MongoUpdate, scallback: Callback, fcallback: Callback, ): this { // we do not want to alter the original data; use it as a prototype var update = data; // save the stack so we can track this call via the oplog var _self = this; this._collection!.update( { id: quote.getId() }, { '$set': update }, {}, function( err, _docs ) { if ( err ) { _self.emit( 'saveQuoteError', err, quote ); if ( typeof fcallback === 'function' ) { fcallback( quote ); } return; } if ( typeof scallback === 'function' ) { scallback( quote ); } } ); return this; } /** * Merges bucket data with the existing bucket (rather than overwriting the * entire bucket) * * @param {Quote} quote quote to save * @param {Object} data bucket data * @param {Function} scallback successful callback * @param {Function} fcallback failure callback * * @return {MongoServerDao} self */ mergeBucket( quote: ServerSideQuote, data: MongoUpdate, success: Callback, failure: Callback, ): this { var update: MongoUpdate = {}; for ( var field in data ) { if ( !field ) { continue; } update[ 'data.' + field ] = data[ field ]; } return this.mergeData( quote, update, success, failure ); } /** * Saves the quote state to the database * * The quote state includes the current step, the top visited step and the * explicit lock message. * * @param Quote quote the quote to save * @param Function success_callback function to call on success * @param Function failure_callback function to call if save fails * * @return MongoServerDao self */ saveQuoteState( quote: ServerSideQuote, success_callback: Callback, failure_callback: Callback, ) { var update = { currentStepId: quote.getCurrentStepId(), topVisitedStepId: quote.getTopVisitedStepId(), topSavedStepId: quote.getTopSavedStepId(), }; return this.mergeData( quote, update, success_callback, failure_callback ); } saveQuoteClasses( quote: ServerSideQuote, classes: any, success: Callback, failure: Callback, ) { return this.mergeData( quote, { classData: classes }, success, failure ); } /** * Save document metadata (meta field on document) * * Only the provided indexes will be modified (that is---data will be * merged with what is already in the database). * * @param {Quote} quote destination quote * @param {Object} new_meta bucket-formatted data to write * @param {Function} success callback on success * @param {Function} failure callback on error * * @return {undefined} */ saveQuoteMeta( quote: ServerSideQuote, new_meta: any, success: Callback, failure: Callback, ): void { const update: MongoUpdate = {}; for ( var key in new_meta ) { var meta = new_meta[ key ]; for ( var i in meta ) { update[ 'meta.' + key + '.' + i ] = new_meta[ key ][ i ]; } } this.mergeData( quote, update, success, failure ); } /** * Saves the quote lock state to the database * * @param Quote quote the quote to save * @param Function success_callback function to call on success * @param Function failure_callback function to call if save fails * * @return MongoServerDao self */ saveQuoteLockState( quote: ServerSideQuote, success_callback: Callback, failure_callback: Callback, ): this { // lock state is saved by default return this.saveQuote( quote, success_callback, failure_callback, {} ); } /** * Pulls quote data from the database * * @param Integer quote_id id of quote * @param Function( data ) callback function to call when data is available * * @return MongoServerDao self to allow for method chaining */ pullQuote( quote_id: PositiveInteger, callback: ( data: Record | null ) => void ): this { var dao = this; // XXX: TODO: Do not read whole of record into memory; filter out // revisions! this._collection!.find( { id: quote_id }, { limit: 1 }, function( _err, cursor ) { cursor.toArray( function( _err: NullableError, data: any[] ) { // was the quote found? if ( data.length == 0 ) { callback.call( dao, null ); return; } // return the quote data callback.call( dao, data[ 0 ] ); }); } ); return this; } getMinQuoteId( callback: ( min_id: number ) => void ): this { // just in case it's asynchronous later on callback.call( this, this.SEQ_QUOTE_ID_DEFAULT ); return this; } getMaxQuoteId( callback: ( min_id: number ) => void ): void { var dao = this; this._seqCollection!.find( { _id: this.SEQ_QUOTE_ID }, { limit: 1 }, function( _err, cursor ) { cursor.toArray( function( _err: NullableError, data: any[] ) { if ( data.length == 0 ) { callback.call( dao, 0 ); return; } // return the max quote id callback.call( dao, data[ 0 ].val ); }); } ); } getNextQuoteId( callback: ( quote_id: number ) => void ): this { var dao = this; this._seqCollection!.findAndModify( { _id: this.SEQ_QUOTE_ID }, [ [ 'val', 'descending' ] ], { $inc: { val: 1 } }, { 'new': true }, function( err, doc ) { if ( err ) { dao.emit( 'seqError', err ); callback.call( dao, 0 ); return; } // return the new id callback.call( dao, doc.val ); } ); return this; } /** * Create a new revision with the provided quote data * * The revision will contain the whole the quote. If space is a concern, we * can (in the future) calculate a delta instead (Mike recommends the Git * model of storing the deltas in previous revisions and the whole of the * bucket in the most recently created revision). */ createRevision( quote: ServerSideQuote, callback: ErrorCallback, ): void { var _self = this, qid = quote.getId(), data = quote.getBucket().getData(); this._collection!.update( { id: qid }, { '$push': { revisions: { data: data } } }, // create record if it does not yet exist { upsert: true }, // on complete function( err ) { if ( err ) { _self.emit( 'mkrevError', err ); } callback( err ); return; } ); } getRevision( quote: ServerSideQuote, revid: PositiveInteger, callback: ErrorCallback, ): void { revid = +revid; // XXX: TODO: Filter out all but the revision we want this._collection!.find( { id: quote.getId() }, { limit: 1 }, function( _err, cursor ) { cursor.toArray( function( _err: NullableError, data: any[] ) { // was the quote found? if ( ( data.length === 0 ) || ( data[ 0 ].revisions.length < ( revid + 1 ) ) ) { callback( null ); return; } // return the quote data callback( data[ 0 ].revisions[ revid ] ); }); } ); } setWorksheets( qid: QuoteId, data: MongoUpdate, callback: NodeCallback, ): void { this._collection!.update( { id: qid }, { '$set': { worksheets: { data: data } } }, // create record if it does not yet exist { upsert: true }, // on complete function( err ) { callback( err ); return; } ); } getWorksheet( qid: QuoteId, supplier: string, index: PositiveInteger, callback: ( data: WorksheetData | null ) => void, ): void { this._collection!.find( { id: qid }, { limit: 1 }, function( _err, cursor ) { cursor.toArray( function( _err: NullableError, data: any[] ) { // was the quote found? if ( ( data.length === 0 ) || ( !data[ 0 ].worksheets ) || ( !data[ 0 ].worksheets.data ) || ( !data[ 0 ].worksheets.data[ supplier ] ) ) { callback( null ); return; } // return the quote data callback( data[ 0 ].worksheets.data[ supplier ][ index ] ); } ); } ); } };