/* 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) } }