UNPKG

metastocle

Version:
645 lines (585 loc) 20.2 kB
import { assign, flatten, get, groupBy, isPlainObject, merge, omitBy, orderBy } from "lodash-es"; import node from "spreadable/src/node.js"; import pack from "../package.json" with { type: "json" }; import collection from "./collection/transports/collection/index.js"; import loki from "./db/transports/loki/index.js"; import errors from "./errors.js"; import schema from "./schema.js"; import express from "./server/transports/express/index.js"; import utils from "./utils.js"; const DatabaseLokiMetastocle = loki(); const ServerExpressMetastocle = express(); const Collection = collection(); const Node = node(); export default (Parent) => { /** * Class to manage the node */ return class NodeMetastocle extends (Parent || Node) { static get version() { return pack.version; } static get codename() { return pack.name; } static get DatabaseTransport() { return DatabaseLokiMetastocle; } static get ServerTransport() { return ServerExpressMetastocle; } /** * @see Node */ constructor(options = {}) { options = merge({ request: { documentAdditionNodeTimeout: '2s' }, collections: {}, }, options); super(options); } /** * @see Node.prototype.sync */ async sync() { await super.sync.apply(this, arguments); await this.db.normalizeCollections(); } /** * @see Node.prototype.getStatusInfo */ async getStatusInfo(pretty = false) { const documents = []; const collections = []; for (let i = 0; i < this.__services.length; i++) { const service = this.__services[i]; if (service.type !== 'collection') { continue; } collections.push(service.name); } for (let i = 0; i < collections.length; i++) { documents.push(await this.db.getCollectionSize(collections[i])); } return merge(await super.getStatusInfo(pretty), { collections, documents }); } /** * Prepare the services * * @async */ async prepareServices() { await super.prepareServices.apply(this, arguments); await this.prepareCollections(); } /** * Prepare the collections * * @async */ async prepareCollections() { for (let key in this.options.collections) { await this.addCollection(key, this.options.collections[key]); } } /** * Add the collection * * @async * @param {string} name * @param {Collection} collection */ async addCollection(name, collection) { isPlainObject(collection) && (collection = new Collection(collection)); if (this.__initialized) { this.logger.warn(`Add collection "${name}" before the node initialization`); } return await this.addService(name, collection, 'collection'); } /** * Get the collection * * @async * @param {string} name * @returns {object|null} */ async getCollection(name) { return await this.getService(name, 'collection'); } /** * Remove the collection * * @async * @param {string} name */ async removeCollection(name) { return await this.removeService(name, 'collection'); } /** * Test the collection by name * * @param {string} name */ async collectionTest(name) { if (!await this.getCollection(name)) { throw new errors.WorkError(`Collection ${name} doesn't exist`, 'ERR_METASTOCLE_NOT_FOUND_COLLECTION'); } } /** * Test the document * * @param {object} document */ async documentTest(document) { if (!utils.isDocument(document)) { throw new errors.WorkError(`Invalid document: ${JSON.stringify(document, null, 1)}`, 'ERR_METASTOCLE_INVALID_DOCUMENT'); } } /** * Check the collection by name * * @see NodeMetastocle.prototype.collectionTest * @returns {boolean} */ async checkCollection(name) { return !!(await this.getCollection(name)); } /** * Add the document to the network * * @async * @param {string} collectionName * @param {object} document * @param {object} [options] * @returns {object} */ async addDocument(collectionName, document, options = {}) { const existenceErrFn = () => { if (options.ignoreExistenceError) { return preparedExtDoc; } const data = JSON.stringify(document, null, 1); throw new errors.WorkError(`Document ${data} already exists`, 'ERR_METASTOCLE_DOCUMENT_EXISTS'); }; await this.documentTest(document); await this.collectionTest(collectionName); const timer = this.createRequestTimer(options.timeout); const collection = await this.getCollection(collectionName); const info = { collection: collectionName }; document = await collection.prepareDocumentToAdd(document); collection.pk && (info.pkValue = get(document, collection.pk)); const masterRequestTimeout = await this.getRequestMasterTimeout(); const results = await this.requestNetwork('get-document-addition-info', { body: { info }, timeout: timer([masterRequestTimeout, this.options.request.documentAdditionNodeTimeout], { min: masterRequestTimeout, grabFree: true }), responseSchema: schema.getDocumentAdditionInfoMasterResponse({ schema: collection.schema }) }); const existing = flatten(results).reduce((p, c) => p.concat(c.existing), []); const duplicatesCount = await this.getDocumentDuplicatesCount(info); const limit = duplicatesCount - existing.length; const extDoc = this.extractDocumentExistenceInfo(existing); const preparedExtDoc = extDoc ? await collection.prepareDocumentToGet(extDoc) : null; document = extDoc ? extDoc : document; document = merge(document, { [collection.duplicationKey]: document[collection.duplicationKey] }); if (limit <= 0) { return existenceErrFn(); } const filterOptions = Object.assign(await this.getDocumentAdditionInfoFilterOptions(info), { limit }); const candidates = await this.filterCandidatesMatrix(results.map(r => r.candidates), filterOptions); if (!candidates.length && !existing.length) { throw new errors.WorkError('Not found a suitable server to add the document', 'ERR_METASTOCLE_NOT_FOUND_SERVER'); } if (!candidates.length) { return existenceErrFn(); } await this.db.addBehaviorCandidate('addDocument', candidates[0].address); const servers = candidates.map(c => c.address).sort(await this.createAddressComparisonFunction()); const result = await this.duplicateDocument(servers, document, info, { timeout: timer() }); if (!result && !existing.length) { throw new errors.WorkError('Not found an available server to add the document', 'ERR_METASTOCLE_NOT_FOUND_SERVER'); } if (existing.length) { return existenceErrFn(); } return await collection.prepareDocumentToGet(result.document); } /** * Update the documents * * @async * @param {string} collectionName * @param {object} document * @param {object} [options] * @returns {object} */ async updateDocuments(collectionName, document, options = {}) { await this.documentTest(document); await this.collectionTest(collectionName); const collection = await this.getCollection(collectionName); document = await collection.prepareDocumentToUpdate(document); const actions = utils.prepareDocumentUpdateActions(options); await collection.actionsUpdateTest(actions); const results = await this.requestNetwork('update-documents', { body: { actions, collection: collectionName, document }, timeout: options.timeout, responseSchema: schema.updateDocumentsMasterResponse() }); const updated = results.reduce((p, c) => p + c.updated, 0); return { updated }; } /** * Delete the documents * * @async * @param {string} collectionName * @param {object} [options] * @returns {object} */ async deleteDocuments(collectionName, options = {}) { await this.collectionTest(collectionName); const collection = await this.getCollection(collectionName); const actions = utils.prepareDocumentUpdateActions(options); await collection.actionsDeletionTest(actions); const results = await this.requestNetwork('delete-documents', { body: { actions, collection: collectionName }, timeout: options.timeout, responseSchema: schema.deleteDocumentsMasterResponse() }); const deleted = results.reduce((p, c) => p + c.deleted, 0); return { deleted }; } /** * Get documents * * @async * @param {string} collectionName * @param {object} [options] * @returns {object} */ async getDocuments(collectionName, options = {}) { await this.collectionTest(collectionName); const collection = await this.getCollection(collectionName); const actions = utils.prepareDocumentGettingActions(options); const isCounting = options.isCounting; const pkValue = options.pkValue; await collection.actionsGettingTest(actions); const results = await this.requestNetwork('get-documents', { body: { actions, collection: collectionName, isCounting, pkValue }, timeout: options.timeout, responseSchema: schema.getDocumentsMasterResponse({ duplicationKey: collection.duplicationKey, schema: collection.schema, isCounting }) }); return await this.handleDocumentsGettingForClient(collection, results, actions); } /** * Get document by the primary key value * * @async * @param {string} collectionName * @param {*} pkValue * @param {object} [options] * @returns {object|null} */ async getDocumentByPk(collectionName, pkValue, options = {}) { await this.collectionTest(collectionName); options = Object.assign({}, options, { limit: 1, offset: 0, pkValue }); const res = await this.getDocuments(collectionName, options); return res.documents.length ? res.documents[0] : null; } /** * Get documents count * * @async * @param {string} collectionName * @param {object} [options] * @returns {number} */ async getDocumentsCount(collectionName, options = {}) { await this.collectionTest(collectionName); options = Object.assign({}, options, { limit: 0, offset: 0, sort: null, isCounting: true }); const res = await this.getDocuments(collectionName, options); return res.totalCount; } /** * @see NodeMetastocle.prototype.handleDocumentsGettingForButler */ async handleDocumentsGettingForMaster() { return await this.handleDocumentsGettingForButler(...arguments); } /** * Handle the documents getting on the master side * * @async * @param {object} collection * @param {array} arr * @param {object} [actions] * @returns {object} */ async handleDocumentsGettingForButler(collection, arr, actions = {}) { let documents = arr.reduce((p, c) => p.concat(c.documents), []); actions.removeDuplicates && (documents = this.uniqDocuments(collection, documents)); return { documents }; } /** * Handle the documents getting on the client side * * @async * @param {object} collection * @param {array} arr * @param {object} [actions] * @returns {object} */ async handleDocumentsGettingForClient(collection, arr, actions = {}) { let documents = arr.reduce((p, c) => p.concat(c.documents), []); actions.removeDuplicates && (documents = this.uniqDocuments(collection, documents)); const handler = new collection.constructor.DocumentsHandler(documents); actions.sort && handler.sortDocuments(actions.sort); const totalCount = handler.getDocuments().length; (actions.limit || actions.offset) && handler.limitDocuments(actions.offset, actions.limit); documents = handler.getDocuments(); for (let i = 0; i < documents.length; i++) { documents[i] = await collection.prepareDocumentToGet(documents[i]); } return { documents, totalCount }; } /** * Handle the documents getting on the slave side * * @async * @param {object} collection * @param {object[]} documents * @param {object} [actions] * @returns {object} */ async handleDocumentsGettingForSlave(collection, documents, actions = {}) { const handler = new collection.constructor.DocumentsHandler(documents); actions.filter && handler.filterDocuments(actions.filter); const accessDocuments = handler.getDocuments(); actions.fields && handler.fieldDocuments(actions.fields); documents = handler.getDocuments(); return { documents, accessDocuments }; } /** * Handle the documents update on the slave side * * @async * @param {object} collection * @param {object[]} documents * @param {object} document * @param {object} [actions] * @returns {object} */ async handleDocumentsUpdate(collection, documents, document, actions = {}) { const handler = new collection.constructor.DocumentsHandler(documents); actions.filter && handler.filterDocuments(actions.filter); documents = handler.getDocuments().map(d => { if (actions.replace) { return merge({}, omitBy(d, (v, k) => !k.startsWith('$')), document); } return merge({}, d, omitBy(document, (v, k) => k.startsWith('$'))); }); return { documents }; } /** * Handle the documents deletion on the slave side * * @async * @param {object} collection * @param {object[]} documents * @param {object} [actions] * @returns {object} */ async handleDocumentsDeletion(collection, documents, actions = {}) { const handler = new collection.constructor.DocumentsHandler(documents); actions.filter && handler.filterDocuments(actions.filter); documents = handler.getDocuments(); return { documents }; } /** * Duplicate the document * * @async * @param {string[]} servers * @param {object[]} existing * @param {object} document * @param {object} info * @param {object} [options] * @returns {object} */ async duplicateDocument(servers, document, info, options = {}) { const collection = await this.getCollection(info.collection); options = assign({ responseSchema: schema.getDocumentAdditionResponse({ schema: collection.schema }) }, options); options.body = { info, document }; options.serverOptions = { timeout: this.options.request.documentAdditionNodeTimeout }; return await this.duplicateData('add-document', servers, options); } /** * get the document existence info * * @see NodeMetastocle.prototype.documentAvailabilityTest * @returns {object|nfull} */ async getDocumentExistenceInfo(info) { if (info.pkValue === undefined) { return null; } const collection = await this.getCollection(info.collection); return await this.db.getDocumentByPk(collection.name, info.pkValue); } /** * Check the document fullness * * @see NodeMetastocle.prototype.documentAvailabilityTest * @returns {boolean} */ async checkDocumentFullness(info) { const collection = await this.getCollection(info.collection); const count = info.count || await this.db.getCollectionSize(collection.name); return count >= collection.limit; } /** * Check the document availability * * @see NodeMetastocle.prototype.documentAvailabilityTest * @returns {boolean} */ async checkDocumentAvailability() { try { await this.documentAvailabilityTest(...arguments); return true; } catch (err) { if (err instanceof errors.WorkError) { return false; } throw err; } } /** * Test the document availability * * @async * @param {object} info * @param {string} info.collection * @param {number} [info.count] * @param {*} [info.pkValue] */ async documentAvailabilityTest(info = {}) { const collection = await this.getCollection(info.collection); const count = info.count || await this.db.getCollectionSize(collection.name); if (!collection.queue && collection.limit && count >= collection.limit) { const msg = `Too much documents are in the collection "${collection.name}"`; throw new errors.WorkError(msg, 'ERR_METASCTOCLE_DOCUMENTS_LIMITED'); } } /** * Get the document duplicates count * * @async * @param {object} info * @param {string} info.collection * @returns {number} */ async getDocumentDuplicatesCount(info) { const collection = await this.getCollection(info.collection); return this.getValueGivenNetworkSize(collection.preferredDuplicates); } /** * Get the document addition filter options * * @async * @param {object} info * @returns {object} */ async getDocumentAdditionInfoFilterOptions(info) { return { uniq: 'address', fnCompare: await this.createSuspicionComparisonFunction('addDocument', await this.createDocumentAdditionComparisonFunction()), fnFilter: c => !c.existenceInfo && c.isAvailable, schema: schema.getDocumentAdditionInfoSlaveResponse(), limit: await this.getDocumentDuplicatesCount(info) }; } /** * Create a document addition comparison function * * @async * @returns {function} */ async createDocumentAdditionComparisonFunction() { return (a, b) => { if (a.isFull && !b.isFull) { return 1; } if (b.isFull && !a.isFull) { return -1; } return a.count - b.count; }; } /** * Extract the existence document info * * @see NodeMetastocle.prototype.chooseDocumentsDuplicate */ extractDocumentExistenceInfo(arr) { return this.chooseDocumentsDuplicate(arr.map(item => item.existenceInfo)); } /** * Choose the documents duplicate * * @param {array} arr * @returns {object|null} */ chooseDocumentsDuplicate(arr) { arr = orderBy(arr, ['$createdAt'], ['asc']); return arr[0] || null; } /** * Remove the document duplicates * * @param {object} collection * @param {object[]} documents * @returns {object[]} */ uniqDocuments(collection, documents) { const group = Object.values(groupBy(documents, collection.duplicationKey)); return group.map(d => this.chooseDocumentsDuplicate(d)).filter(d => d); } /** * Create the document schema * * @param {object} scheme * @param {string} duplicationKey * @returns {object} */ createDocumentFullSchema(scheme, duplicationKey) { if (scheme && typeof scheme != 'object' || Array.isArray(scheme)) { throw new Error('Document schema must be an object'); } if (!scheme) { return; } scheme = merge({ expected: true }, scheme, schema.getDocumentSystemFields({ duplicationKey })); return scheme; } /** * Prepare the options */ prepareOptions() { super.prepareOptions(); this.options.request.documentAdditionNodeTimeout = utils.getMs(this.options.request.documentAdditionNodeTimeout); } }; };