Source: DataEntity.js

/* global localStorage, navigator, window */
import EventSystem from './EventSystem'
// import Dexie from 'dexie'
import mongoose from 'mongoose'
import { createMethodSignature, toJSON } from './utils'

/**
 * @author Eduardo Perotta de Almeida <web2solucoes@gmail.com>
 * @Class DataEntity
 * @summary Data Entity API
 * @description When composing web applications using this library, we strongly believe the data design and plan should be the entry point of your software design.
 * <br><br>The Entity Relationship diagram shall to be one of the initial documents you should to design before starting to write your software.
 * <br><br>We assume the {@link https://en.wikipedia.org/wiki/Entity%E2%80%93relationship_model|Entity Relationship} and {@link https://en.wikipedia.org/wiki/Entity%E2%80%93relationship_model|Data Entity} models as technique and paradigm to design the application data. 
 * <br><br>If you have no idea of how agile you could to design your ER diagram, please take a look at some tools like {@Link https://www.datensen.com/data-modeling/moon-modeler-for-databases.html|Moon Modeler}
 * <br><br> Every Data Entity in the system has it own encapsulated properties and methods that cares about where entity data is writen to and read from. 
 * <br><br> The DataEntity relies on the application instance (passed to it constructor) to access the available data transports. 
 * <br>It means you can not use DataEntity prior starting a data transport layer. 
 * <br><br> This class is not for direct usage in your project, unless you are a core developer or want to understand what happens behind the scenes, you should consider to take a look at the {@link Foundation} class.
 * @extends EventSystem
 * @param  {object} DataEntityConfig - Data Entity configuration
 * @param  {string} DataEntityConfig.foundation - Provide Accesss to Foundation scope
 * @param  {string} DataEntityConfig.entity - Data entity name which this dataEntity instance is handling
 * @param  {boolean} DataEntityConfig.strategy - Data transport strategy
 * @param  {boolean} DataEntityConfig.schema - Data schema for this Data Entity abstraction. <br> Do not declare the params __id and _id inside your schemas.
 * @example
import { Schema } from '../foundation/Foundation'

const schema = new Schema({
  // do not declare __id
  // do not declare _id
  name: {
    type: String,
    required: true,
    index: true
  },
  address: {
    type: String,
    required: true,
    index: true
  },
  email: {
    type: String,
    required: true,
    index: true
  },
  cards: {
    type: [],
    required: true
  }
})

const Customer = new DataEntity({
  foundation, // Foundation instance, object
  entity: 'Customer', // entity name, string
  strategy: 'offline', // data strategy, string
  schema // data schema, a mongoose like schema
})

// listen to add Customer Data event on Data API
const onAddDocEventListener = Customer.on(
  'add',
  function(eventObj){
    const { error, document, foundation, data } = eventObj
    // do something like to update the component View state Based On Information from Incoming Event
    component.setState({ propertyName: newValue }) // React: 
    component.$set(component.data, 'propertyName', newValue) // Vue:

  }
)
// listen to edit Customer Data event on Data API
const onEditDocEventListener = Customer.on(
  'edit',
  function (eventObj) {
    const { error, document, foundation, data } = eventObj
    // do something like to update the component View state Based On Information from Incoming Event
    component.setState({ propertyName: newValue }) // React: 
    component.$set(component.data, 'propertyName', newValue) // Vue:
  }
)
// listen to delete Customer Data event on Data API
const onDeleteDocEventListener = Customer.on(
  'delete',
  function (eventObj) {
    const { error, document, foundation, data } = eventObj
    // do something like to update the component View state Based On Information from Incoming Event
    component.setState({ propertyName: newValue }) // React: 
    component.$set(component.data, 'propertyName', newValue) // Vue:
  }
)

// Stop to listen to events to avoid memory leak or others kind of problems
// like to change the state of an unmounted component.
// Do something like this -> before component unmount OR before window unload
Customer.stopListenTo(onAddDocEventListener)
Customer.stopListenTo(onEditDocEventListener)
Customer.stopListenTo(onDeleteDocEventListener)

 */

export default class DataEntity extends EventSystem {

  #_foundation
  #_entity
  #_strategy
  #_schema
  #_pagination
  #_stateChangeStorageName

  constructor({
    foundation,
    entity,
    strategy = 'offline',
    schema
  }) {
    super()
    /* if (Object.keys(foundation).length === 0) {
      throw new Error('foundation is invalid')
    }
    if (Object.keys(schema).length === 0) {
      throw new Error('schema is invalid')
    }
    if (entity === null) {
      throw new Error('entity is invalid')
    } */
    this.#_entity = entity
    /**
     * @memberof DataEntity
     * @member {property} DataEntity.#_strategy
     * @summary PRIVATE - Holds the data transport strategy for this Data Entity.
     * @description 
     * Default strategy is <b>offline</b>. <br><br>
     * Possible values are: <br>
     * - offlineFirst<br>
     * Data will be saved on local database first.<br>
     * - onlineFirst<br>
     * Data will be saved on remote database first.<br>
     * - offline<br>
     * Data will be saved on local database only.<br>
     * - online<br>
     * Data will be saved on remote database only.<br>
     */
    this.#_strategy = strategy // offlineFirst, onlineFirst, offline, online
    /**
     * @memberof DataEntity
     * @member {property} DataEntity.#_schema
     * @summary PRIVATE - Holds the data schema for this Data Entity
     * @description Data schema is a mongoose.Schema implementation
     */
    this.#_schema = schema
    this.#_foundation = foundation
    /**
     * @memberof DataEntity
     * @member {property} DataEntity.#_pagination
     * @summary PRIVATE - default internal paging configuration
     * @description The default paging configuration is: offset: 0, limit 30. It means it will returns 30 documents starting on index 0.
     */
    this.#_pagination = {
      offset: 0,
      limit: 30
    }
    this.#_stateChangeStorageName = `__$tabEntityStateChange_${this.#_entity}`
    
    this.#_foundation.localDatabaseTransport.addSchema(this.#_entity, this.#_schema)
    
    this.#_listenToAllOtherSessionsStateChanges()
  }

  /**
   * @memberof DataEntity
   * @member {getter} DataEntity.entity
   * @example // console.log(DataEntity.entity)
   * @description Gets the entity name which which DataEntity instance is handling out
   * @return {object} this.#_entity
   */
  get entity () {
    return this.#_entity
  }

  /**
   * @memberof DataEntity
   * @member {getter} DataEntity.schema
   * @example // console.log(DataEntity.schema)
   * @description Gets the data schema related to this Entity Data API
   * @return {object} this.#_schema
   */
  get schema () {
    return this.#_schema
  }

  /**
   * @memberof DataEntity
   * @member {getter} DataEntity.strategy
   * @example // console.log(DataEntity.strategy)
   * @description Gets the data strategy currently being used
   * @return {string} this.#_strategy
   */
  get strategy () {
    return this.#_strategy
  }

  /**
   * @Method DataEntity.Model
   * @description create a Data Model based on given document
   * @param  {object} doc - A valid document validated against mongoose schema
   * @param  {object} schema - Mongoose based schema
   * @return  {object} model - Mongoose document
  */
  Model(doc, schema) {
    const modelSystem = mongoose.Document
    modelSystem.prototype.isNotValid = modelSystem.prototype.validateSync
    return modelSystem(doc, schema)
  }
  /**
   * @async
   * @Method DataEntity.add
   * @description add a new document to the storage
   * @param  {object} doc - A valid document validated against mongoose schema
   * @return  {object} signature - Default methods signature format { error, data }
   * @return  {string|object} signature.error - Execution error
   * @return  {object} signature.data - Created document
   * @example 
const doc = { 
  name: 'Eduardo Almeida',
  address: 'Av. Beira Mar. Praia do Morro, Guarapari - ES. Brazil.',
  email: 'web2solucoes@gmail.com',
  cards: []
}
const { data, error } = await Customer.add(doc)
  */
  async add(doc = {}) {
    // if (!(doc instanceof Document)) {
      // return createMethodSignature('You must pass a valid JSON document as parameter to DataEntity.add() method', null)
    // }
    if (Object.keys(doc).length < 1) {
      return createMethodSignature('You must pass a valid JSON document as parameter to DataEntity.add() method', null)
    }
    let data = null
    let error = null
    let rawObj = {}
    // console.log('doc', doc)
    delete doc.__id
    delete doc._id

    const model = new this.Model(doc, this.#_schema)
    // console.log('model', model)
    const invalid = model.validateSync()
    if (invalid) {
      return createMethodSignature(invalid, data)
    }
    rawObj = toJSON(model)
    // console.log('add', rawObj)
    // bug
    if (typeof rawObj._id === 'undefined' && typeof rawObj.id !== 'undefined') {
      rawObj._id = rawObj.id
    }

    try {
      const __id = await this.#_foundation.localDatabaseTransport
        .table(this.#_entity)
          .add({ ...rawObj })
      data = { __id, ...rawObj }
      // console.log('data', data)
      // console.log('__id', __id)
    } catch (e) {
      // console.log('error error ', e)
      error = e
    }
    // console.log({ data, error, rawObj })
    this.#_triggerAddEvents({ data, error, primaryKey: (typeof data.__id === 'undefined' ? 0 : data.__id), rawObj })
    return createMethodSignature(error, data)
  }

  /**
   * @Method DataEntity.#_triggerAddEvents
   * @description PRIVATE - Triggers all events related to 'add document' event
   * @param  {object} eventPayload - Object containing all information about the event
   * @param  {object} eventPayload.data - The new document inserted into database, default is null if not provided
   * @param  {object|string} eventPayload.error - The returned error from database add request if any, default is null if not provided
   * @param  {number} eventPayload.primaryKey - The primaryKey value of the added document, default is zero if not provided
   * @param  {object} eventPayload.rawObj - The raw document object provided on dataEntity.add(doc) mehod call. Default is {} if not provided.
  */
  #_triggerAddEvents({ data, error, primaryKey, rawObj}) {
    const action = 'add'
    this.#_foundation.triggerEvent(`collection:${action}:${this.#_entity.toLowerCase()}`, {
      foundation: this.#_foundation,
      entity: this.#_entity,
      document: rawObj,
      primaryKey,
      data,
      error,
    })
    this.triggerEvent(action, {
      foundation: this.#_foundation,
      entity: this.#_entity,
      document: rawObj,
      primaryKey,
      data,
      error,
    })
    const state = { action, data, error, document: rawObj, primaryKey }
    this.#_sendStateChangeToAllOtherSessions(state)
  }

  /**
   * @async
   * @Method DataEntity.edit
   * @description Edit a document on the storage
   * @param  {string|number} primaryKey - The primary key value of the desired document
   * @param  {object} doc - A valid document validated against mongoose schema
   * @return  {object} signature - Default methods signature format { error, data }
   * @return  {string|object} signature.error - Execution error
   * @return  {object} signature.data - Edited document
   * @example 
const doc = {
  __id: 1,
  _id: '601cb8d8623dc60000ee3c24',
  name: 'Eduardo Almeida',
  address: 'Av. Beira Mar. Praia do Morro, Guarapari - ES. Brazil.',
  email: 'web2solucoes@gmail.com',
  cards: []
}
const { data, error } = await Customer.edit(doc.__id, doc)
   */
  async edit(primaryKey = null, doc = {}) {
    // if (!(doc instanceof Document)) {
      // return createMethodSignature('You must pass a valid JSON document as parameter to DataEntity.edit() method', null)
    // }
    if (Object.keys(doc).length < 1) {
      return createMethodSignature('You must pass a valid JSON document as parameter to DataEntity.edit() method', null)
    }
    if (primaryKey === null) {
      return createMethodSignature('You must pass a valid primary key value as parameter to DataEntity.edit() method', null)
    }
    if (typeof doc.__id !== 'number') {
      return createMethodSignature('Document must have doc.__id (Integer) when calling DataEntity.edit() method', null)
    }
    if (typeof doc._id !== 'string') {
      return createMethodSignature('Document must have doc._id (ObjectID) when calling DataEntity.edit() method', null)
    }
    primaryKey = +primaryKey
    let data = null
    let error = null
    let rawObj = {}
    try {
      const model = new this.Model(doc, this.#_schema)
      const invalid = model.validateSync()
      if (invalid) {
        return createMethodSignature(invalid, data)
      }
      rawObj = toJSON(model)
      rawObj.__id = primaryKey
      // bug
      const response = await this.#_foundation.localDatabaseTransport
        .table(this.#_entity)
        .put({ ...rawObj })
        // .update({ __id: primaryKey }, { ...rawObj })
      data = { __id: primaryKey, ...rawObj }
      /* if (response.modifiedCount === 1) {
        data = { __id: primaryKey, ...rawObj }
      } else {
        data = null
        error = {
          message: 'Critical query error on update',
          response
        }
      } */
    } catch (e) {
      error = e
    }
    this.#_triggerEditEvents({ data, error, primaryKey, rawObj })
    return createMethodSignature(error, data)
  }

  /**
   * @Method DataEntity.#_triggerEditEvents
   * @description PRIVATE - Triggers all events related to 'edit document' event
   * @param  {object} eventPayload - Object containing all information about the event
   * @param  {object} eventPayload.data - The new document updated into database, default is null if not provided
   * @param  {object|string} eventPayload.error - The returned error from database edit request if any, default is null if not provided
   * @param  {number} eventPayload.primaryKey - The primaryKey value of the edited document, default is zero if not provided
   * @param  {object} eventPayload.rawObj - The raw document object provided on dataEntity.edit(primaryKey, doc) mehod call. Default is {} if not provided.
  */
  #_triggerEditEvents({ data, error, primaryKey, rawObj }) {
    const action = 'edit'
    this.#_foundation.triggerEvent(
      `collection:${action}:${this.#_entity.toLowerCase()}`,
      {
        foundation: this.#_foundation,
        entity: this.#_entity,
        primaryKey,
        document: rawObj,
        data,
        error
      }
    )
    this.triggerEvent(
      action,
      {
        foundation: this.#_foundation,
        entity: this.#_entity,
        primaryKey,
        document: rawObj,
        data,
        error
      }
    )
    const state = { action, data, error, document: rawObj, primaryKey }
    this.#_sendStateChangeToAllOtherSessions(state)
  }

  /**
   * @async
   * @Method DataEntity.delete
   * @description delete a document from the storage
   * @param  {string|number} primaryKey - The primary key value of the desired document
   * @return  {object} signature - Default methods signature format { error, data }
   * @return  {string|object} signature.error - Execution error
   * @return  {object} signature.data - Deleted document
   */
  async delete(primaryKey) {
    primaryKey = +primaryKey
    let data = null
    let error = null
    let rawObj = {}
    try {
      const __id = await this.#_foundation.localDatabaseTransport
        .table(this.#_entity)
         .delete(primaryKey)
      // console.error({ __id })
      data = { __id: primaryKey }
    } catch (e) {
      error = e
    }
    this.#_triggerDeleteEvents({ data, error, primaryKey })
    return createMethodSignature(error, data)
  }

  /**
   * @Method DataEntity.#_triggerDeleteEvents
   * @description PRIVATE - Triggers all events related to 'delete document' event
   * @param  {object} eventPayload - Object containing all information about the event
   * @param  {object} eventPayload.data - A object containing the __id property of the deleted document, default is null if not provided
   * @param  {object|string} eventPayload.error - The returned error from database edit request if any, default is null if not provided
   * @param  {number} eventPayload.primaryKey - The primaryKey value of the deleted document, default is zero if not provided
  */
  #_triggerDeleteEvents({ data, error, primaryKey }) {
    const action = 'delete'
    this.#_foundation.triggerEvent(
      `collection:${action}:${this.#_entity.toLowerCase()}`,
      {
        foundation: this.#_foundation,
        entity: this.#_entity,
        primaryKey,
        data,
        error
      }
    )
    this.triggerEvent(
      action,
      {
        foundation: this.#_foundation,
        entity: this.#_entity,
        primaryKey,
        data,
        error
      }
    )
    const state = { action, data, error, primaryKey }
    this.#_sendStateChangeToAllOtherSessions(state)
  }

  /**
   * @async
   * @Method DataEntity.findById
   * @description find a document from the storage by ID
   * @param  {string|number} primaryKey - The primary key value of the desired document
   * @return  {object} signature - Default methods signature format { error, data }
   * @return  {string|object} signature.error - Execution error
   * @return  {object} signature.data - Found document
   */
  async findById (primaryKey) {
    let data = null
    let error = null
    try {
      primaryKey = parseInt(primaryKey)
      const doc = await this.#_foundation.localDatabaseTransport
        .table(this.#_entity)
          .get(primaryKey)
      // console.log({ __id: primaryKey, doc })
      if (doc)
      {
        if (doc.__id === primaryKey) {
          data = { __id: primaryKey, ...doc }
        }
      }
    } catch (e) {
      error = e
    }
    return createMethodSignature(error, data)
  }

  /**
   * @async
   * @Method DataEntity.findAll
   * @summary Find all documents
   * @description This method will to return all documents based on the given query. If no query is specified, it will returns all records from this collection
   * @return  {object} signature - Default methods signature format { error, data }
   * @return  {string|object} signature.error - Execution error
   * @return  {array} signature.data - Array of Found documents
   */
  async findAll(query = {}) {
    let data = null
    let error = null
    try {
      const documents = await this.#_foundation
          .localDatabaseTransport
            .collection(this.#_entity)
              .find(query)
                  .toArray()       
      data = documents
    } catch (e) {
      error = e
    }
    return createMethodSignature(error, data)
  }

  /**
   * @async
   * @Method DataEntity.find
   * @summary find documents based on the given query and returns a paginated response
   * @description This method will to return the documents based on the given query and the specified paging. If no query is specified, it will returns documents based on paging only.
   * @param  {object|null} query - The query object to search documents
   * @param  {object} pagination - Pagination object. If not provided will assume internaly set pagination.
   * @param  {number} pagination.offset - Offset. Default 0.
   * @param  {number} pagination.limit - Limit. Default 30.
   * @example
        User.find({
          $or: [{ age: { $lt: 23, $ne: 20 } }, { lastname: { $in: ['Fox'] } }]
        })
   * @return  {object} signature - Default methods signature format { error, data }
   * @return  {string|object} signature.error - Execution error
   * @return  {array} signature.data - Array of Found documents
   */
  async find(query = {}, pagination = this.#_pagination) {
    let { offset, limit } = pagination
    let data = null
    let error = null
    try {
      const documents = await this.#_foundation
          .localDatabaseTransport
            .collection(this.#_entity)
              .find(query)
                .reverse() 
                  .offset(offset)
                    .limit(limit)
                      .toArray()       
      data = documents
    } catch (e) {
      error = e
    }
    return createMethodSignature(error, data)
  }

  /**
   * @async
   * @Method DataEntity.count
   * @description count all documents based on the given query
   * @param  {object} query - The query object to count documents
   * @example
        User.count({
          $or: [{ age: { $lt: 23, $ne: 20 } }, { lastname: { $in: ['Fox'] } }]
        })
   * @return  {object} signature - Default methods signature format { error, data }
   * @return  {string|object} signature.error - Execution error
   * @return  {number} signature.data - Documents counter
   */
  async count (query = {}) {
    let data = null
    let error = null
    try {
      const counter = await this.#_foundation
          .localDatabaseTransport
            .collection(this.#_entity)
              .count(query)     
      data = counter
    } catch (e) {
      error = e
    }
    return createMethodSignature(error, data)
  }

  /**
   * @Method DataEntity.#_listenToAllOtherSessionsStateChanges
   * @summary PRIVATE - Listen to data state changes on every Application session.
   * @description Listen to data state change event incoming from every other Application session and communicates to every subscriber tied to this session.
   * <br><br>  The application scope is the browser running the application. 
   * <br><br>  Every tab is considered a session. 
   * <br> <br> Internally it triggers all events related to data change events, except if the source, the session which originated the event, is the same that is receiving the event
   * <br> <br> It does not rely on network to propagate the changes.
   * @example 
        this.#_listenToAllOtherSessionsStateChanges()
  */
  #_listenToAllOtherSessionsStateChanges() {
    window.addEventListener('storage', (event) => {
      if (event.key === this.#_stateChangeStorageName)
      {
        const { key, newValue, oldValue } = event
        if (newValue)
        {
          // console.log('DATAAPI -> got new state change', { key, newValue, oldValue })
          const jsonState = JSON.parse(newValue)
          const { error, data, entity, action, source, document } = jsonState
          // console.error({ error, data, entity, action, source, document })
          const eventObj = {
            foundation: this.#_foundation,
            entity: entity,
            document: document,
            data,
            error
          }
          this.#_foundation.triggerEvent(`collection:${action}:${entity.toLowerCase()}`, eventObj)
          this.triggerEvent(action, eventObj)
        }
        // oldValue
      }
    })
  }

  /**
   * @Method DataEntity.#_sendStateChangeToAllOtherSessions
   * @summary PRIVATE - Sends data state changes information to every other current application session.
   * @description The application scope is the browser running the application. <br> Every tab is considered a session.<br> It can not rely on network.
   * @param  {object} state - Object containing all information about the state
   * @param  {object} state.data - The modified data, default is null if not provided
   * @param  {object|string} state.error - The returned error when trying to modify the data, default is null if not provided
   * @param  {object} state.document - The raw object used as value to get the new data state, default is {} if not provided
   * @example 
        this.#_sendStateChangeToAllOtherSessions({ 
          action: 'add', 
          data: {...newDocument}, 
          error: null, 
          document: {...originalDocument} 
        })
  */
  #_sendStateChangeToAllOtherSessions(state) {
    // state { action: '', data: null, error: null, document: {} }
    state.source = {
      sessionId: this.#_foundation.tabId,
      applicationId: this.#_foundation.guid,
    }
    state.entity = this.#_entity
    const stateChange = JSON.stringify(state)
    window.localStorage.setItem(this.#_stateChangeStorageName, stateChange)
    window.localStorage.removeItem(this.#_stateChangeStorageName)
  }
}