bouncer_db_loki-db.js

'use strict'

const IDb = require('../idb')
const Hoek = require('@hapi/hoek')
const Loki = require('lokijs')
const LokiFS = Loki.LokiFsAdapter
const LFSA = require('lokijs/src/loki-fs-structured-adapter')
const ObjectId = require('bson-objectid')
const uuidv4 = require('uuid/v4')

const MongoQuery = require('../mongo-query')
const { promisfy } = require('promisfy')
const debug = require('debug')('bouncer.db.loki-db')



/**
 * A db implementation based on LokiJS.
 * 
 * Ideal for frontend apps with small datasets (smaller than total RAM). This is an in-memory db so it trades RAM efficiency for access speed.
 * @see https://github.com/techfort/LokiJS
 * 
 * @class  module:Db.LokiDb
 * @extends {module:Db.IDb}
 * @link module:Db
 * @see module:Party.LokiParty
 */
module.exports = class LokiDb extends IDb {

  constructor ({path, factory, dbAdapter, lokiOptions, useUuid}) {
    super(factory)
    debug('constructor')
    this.loki = null
    this.lokiOptions = lokiOptions
    this.path = path
    this.dbAdapter = dbAdapter || new LFSA()
    this.error = null
    this.useUuid = (useUuid==undefined) ? true : useUuid
  }

  static get LokiLocalStorageAdapter(){
    return Loki.LokiLocalStorageAdapter
  }


  async start(){

    debug('starting')

    debug('path',this.path)
    this.loki = new Loki(
      this.path,
      { 
        adapter : this.dbAdapter,
        ...this.lokiOptions
      }
    )

    
    await new Promise((resolve, reject)=>{
      this.loki.loadDatabase({}, resolve)
      debug('started with collections', this.loki.listCollections())
    })
    
    await super.start()
    //await promisfy(this.loki.saveDatabase.bind(this.loki))
    debug('started with collections', this.loki.listCollections())
  }

  async getCollectionNames(){
    const names = this.loki.listCollections()

    return names.map(obj=>{return obj.name.replace(this.prefix,'')})
  }

  async createCollection(name, indexSettings){
    debug('createCollection', name, indexSettings)
    
    /*const existing = this.loki.getCollection(name)
    if(existing !== null){ return }*/

    if(this.hasCollection(name) == true){ return }

    const options = {
      unique: ['$meta.id'].concat(indexSettings.unique),
      indices: ['$meta.id'].concat(indexSettings.indices)
    }

    debug('createCollection', name, options)


    options.unique.push('$meta.id')

    let collection = this.loki.addCollection(this.prefix+name, options)

    debug(collection)
  }

  async getCollection(name){ 
    let collection = this.loki.getCollection(this.prefix+name)

    debug('collections', this.loki.listCollections())

    return collection
  }

  /** convert db documnet to plain object with $meta field */
  documentToObject(doc){
    let obj = Object.assign({},doc)
    obj.$meta = {
      id: Hoek.reach(obj,'$meta.id', {default: obj._id}),
      type: Hoek.reach(doc,'$meta.type'),
      created: Hoek.reach(obj,'$meta.created', {default: (new Date()).toISOString()}),
      revision: Hoek.reach(obj,'$meta.revision', {default: 1}),
      removed: Hoek.reach(obj,'$meta.removed')
    }
    
    delete obj.meta
    delete obj.$loki
    delete obj._id

    return obj
  }


  /** convert object with $meta field to db representation*/
  documentFromObject(obj){ 
    let doc = Object.assign({},obj)
    doc._id = Hoek.reach(obj,'$meta.id', {default: obj._id}),
    doc.$meta = {
      id: Hoek.reach(obj,'$meta.id', {default: obj._id}),
      type: Hoek.reach(obj,'$meta.type'),
      created: Hoek.reach(obj,'$meta.created', {default: (new Date()).toISOString()}),
      revision: Hoek.reach(obj,'$meta.revision', {default: 1}),
      removed: Hoek.reach(obj,'$meta.removed')
    }
    

    //delete doc.$meta

    return doc
  }

  ensureId(obj){
    let temp = {...obj}
    if(!reach(temp,'$meta.id')){

      if(this.useUuid){
        temp.$meta.id = uuidv4()
      }
      else{
        temp.$meta.id = (new ObjectId()).toHexString()
      }
    }

    let dbDoc = this.documentFromObject(temp)
    
    return dbDoc
  }

  /*
  async handleCall(ask){
    debug('handleCall')
    //debug('\task', JSON.stringify(ask,null,2))

    let complete = true
    let results = []

    for(let crufl of ask.crufls){
      let result = {
        op: crufl.op,
        uuid: crufl.uuid,
        msgs: [],
        complete: true,
        error: null
      }

      debug('\tcrufl->', crufl.op, crufl.type)

      //debug('\t\tcrufl ->', crufl)

      switch(crufl.op){
        case 'create':
          result.msgs = await this.applyCreate(crufl)
          break
        case 'remove':
          result.msgs = await this.applyRemove(crufl)
          break
        case 'find':
          result.msgs = await this.applyFind(crufl, false)
          break
        case 'lookup':
          result.msgs = await this.applyFind(crufl, true)
          break
        
        default:
          break
      }

      results.push(result)
    }

    let freshness = {
      uuid: ask.uuid,
      results,
      complete
    }

    //debug('replying', JSON.stringify(freshness,null,2))

    return {freshness: results }
  }*/

  async find(collectionName, mongoQuery){

    let query = mongoQuery.getQueryDoc()

    debug('find collection=', collectionName, ' query=', JSON.stringify(query,null,2))
    let collection = await this.getCollection(collectionName)
    let resultSet = collection.chain().find(query)


    if(mongoQuery.hasLimit()){
      resultSet = resultSet.limit(mongoQuery.getLimit())
    }

    if(mongoQuery.hasSort()){
      let sortPath = Object.keys(mongoQuery.getSort())[0]
      resultSet = resultSet.simplesort( sortPath )
    }

    return resultSet.data().map(this.documentToObject) || []
  }

  async insertMany(collectionName, docs){ 
    debug('insert collection=', collectionName, ' docs=', JSON.stringify(docs,null,2))
    let collection = await this.getCollection(collectionName)

    let resultSet = []

    for(let obj of docs){
      let temp = {...obj}
      if(temp._id===undefined){

        if(this.useUuid){
          temp._id = uuidv4()
        }
        else{
          temp._id = (new ObjectId()).toHexString()
        }

        temp.$meta.id=temp._id;
      }

      

      let dbDoc = this.documentFromObject(temp)

      const stripped = this.stripMeta(temp)

      debug('validating', stripped,'from', temp)

      await this.factory.validate(collectionName, stripped)

      debug('its good, inserting', dbDoc)

      const result = collection.insert( dbDoc )

      debug('inserted', result)

      const finalDbDoc = result
      const finalObj = this.documentToObject(finalDbDoc)

      debug('returning', finalObj)

      this.emitChange(finalObj, 'create')

      resultSet.push(finalObj)
    }


    await promisfy(this.loki.saveDatabase.bind(this.loki))
    return resultSet

  }
  
  async update(collectionName, docs){ 
    debug('update collection', collectionName, ' docs', docs)

    let collection = await this.getCollection(collectionName)

    let objs = []

    for(let obj of docs){
      let dbDoc = this.documentFromObject(obj)

      dbDoc.$meta.revision++

      debug('updating',obj, 'to', dbDoc)

      const stripped = this.stripMeta(dbDoc)

      debug('validating', stripped,'from', dbDoc)

      await this.factory.validate(collectionName, stripped)

      debug('its good, updating', dbDoc)

      let old = collection.findOne( {'$meta.id': dbDoc._id})
      
      let mergedDoc = {...old, ...dbDoc}

      collection.update(mergedDoc)

      const finalObj = this.documentToObject(mergedDoc)

      this.emitChange(finalObj, 'update')

      objs.push( finalObj )

    }

    await promisfy(this.loki.saveDatabase.bind(this.loki))
    return objs
  }

  async findAndRemove(collectionName, obj){ 
    debug('findAndRemove collection', collectionName, ' obj', obj)

    let collection = await this.getCollection(collectionName)

    const dbDoc = collection.findAndRemove( { '$meta.id': obj.$meta.id } )

    let finalObj = {
      $meta: obj.$meta
    }

    finalObj.$meta.removed = true

    this.emitChange(finalObj, 'remove')

    debug('finalObj', finalObj)
    debug('obj', obj)

    await promisfy(this.loki.saveDatabase.bind(this.loki))
    return finalObj
  }

  /*async applyFind(crufl, includeData = false){
    debug('find', JSON.stringify(crufl,null,2))
    let query = new MongoQuery(crufl.spec)

    let MongoQuery = query.getQueryDoc()

    debug('loki-find', JSON.stringify(MongoQuery,null,2))

    let collection = this.loki.getCollection(crufl.type)

    let resultSet = collection.find(MongoQuery)

    //debug(collection)
    //debug('resultSet', resultSet)

    let msgs = []

    for(const result of resultSet){

      if(includeData){

        let msg = Object.assign({},result)
        msg.$meta.revision = msg.meta.revision
        msg.$meta.created = msg.meta.created
        msg.$meta.version = msg.meta.version
        delete msg.meta
        delete msg.$loki

        msgs.push(msg)

      } else{

        msgs.push({
          $meta:{
            id: result.$meta.id,
            type: result.$meta.type
          }
        })

      }
    }

    debug(msgs)
    return msgs
  }

  async applyCreate(crufl){
    let msgs = []

    let collection = this.loki.getCollection(crufl.type)


    for(let createMsg of crufl.msgs){
      let raw = {...createMsg}
      raw.$meta.id = (new ObjectId()).toHexString()
      let doc = collection.insert(Object.assign({},raw))
      

      let msg = Object.assign({},doc)
      msg.$meta.revision = msg.meta.revision
      msg.$meta.created = msg.meta.created
      msg.$meta.version = msg.meta.version
      delete msg.meta
      delete msg.$loki

      msgs.push(msg)
    }

    return msgs
  }

  async applyRemove(crufl){
    let msgs = []

    let collection = this.loki.getCollection(crufl.type)

    for(let rmMsg of crufl.msgs){
      let msg = { $meta: {
        removed: true,
        id: rmMsg.$meta.id,
        type: rmMsg.$meta.type
      }}

      collection.findAndRemove(rmMsg)
      msgs.push(msg)
    }

    return msgs
  }
  */
}