| const _ = require('lodash'); |
| const mongoose = require('mongoose'); |
| const { MeiliSearch } = require('meilisearch'); |
| const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc'); |
| const logger = require('~/config/meiliLogger'); |
|
|
| const searchEnabled = process.env.SEARCH && process.env.SEARCH.toLowerCase() === 'true'; |
| const meiliEnabled = process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY && searchEnabled; |
|
|
| const validateOptions = function (options) { |
| const requiredKeys = ['host', 'apiKey', 'indexName']; |
| requiredKeys.forEach((key) => { |
| if (!options[key]) { |
| throw new Error(`Missing mongoMeili Option: ${key}`); |
| } |
| }); |
| }; |
|
|
| |
| const createMeiliMongooseModel = function ({ index, attributesToIndex }) { |
| const primaryKey = attributesToIndex[0]; |
| |
| class MeiliMongooseModel { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| static async syncWithMeili() { |
| try { |
| let moreDocuments = true; |
| const mongoDocuments = await this.find().lean(); |
| const format = (doc) => _.pick(doc, attributesToIndex); |
|
|
| |
| const mongoMap = new Map(mongoDocuments.map((doc) => [doc[primaryKey], format(doc)])); |
| const indexMap = new Map(); |
| let offset = 0; |
| const batchSize = 1000; |
|
|
| while (moreDocuments) { |
| const batch = await index.getDocuments({ limit: batchSize, offset }); |
|
|
| if (batch.results.length === 0) { |
| moreDocuments = false; |
| } |
|
|
| for (const doc of batch.results) { |
| indexMap.set(doc[primaryKey], format(doc)); |
| } |
|
|
| offset += batchSize; |
| } |
|
|
| logger.debug('[syncWithMeili]', { indexMap: indexMap.size, mongoMap: mongoMap.size }); |
|
|
| const updateOps = []; |
|
|
| |
| for (const [id, doc] of indexMap) { |
| const update = {}; |
| update[primaryKey] = id; |
| if (mongoMap.has(id)) { |
| |
| |
| if ( |
| (doc.text && doc.text !== mongoMap.get(id).text) || |
| (doc.title && doc.title !== mongoMap.get(id).title) |
| ) { |
| logger.debug( |
| `[syncWithMeili] ${id} had document discrepancy in ${ |
| doc.text ? 'text' : 'title' |
| } field`, |
| ); |
| updateOps.push({ |
| updateOne: { filter: update, update: { $set: { _meiliIndex: true } } }, |
| }); |
| await index.addDocuments([doc]); |
| } |
| } else { |
| |
| |
| await index.deleteDocument(id); |
| updateOps.push({ |
| updateOne: { filter: update, update: { $set: { _meiliIndex: false } } }, |
| }); |
| } |
| } |
|
|
| |
| for (const [id, doc] of mongoMap) { |
| const update = {}; |
| update[primaryKey] = id; |
| |
| |
| if (!indexMap.has(id)) { |
| await index.addDocuments([doc]); |
| updateOps.push({ |
| updateOne: { filter: update, update: { $set: { _meiliIndex: true } } }, |
| }); |
| } else if (doc._meiliIndex === false) { |
| updateOps.push({ |
| updateOne: { filter: update, update: { $set: { _meiliIndex: true } } }, |
| }); |
| } |
| } |
|
|
| if (updateOps.length > 0) { |
| await this.collection.bulkWrite(updateOps); |
| logger.debug( |
| `[syncWithMeili] Finished indexing ${ |
| primaryKey === 'messageId' ? 'messages' : 'conversations' |
| }`, |
| ); |
| } |
| } catch (error) { |
| logger.error('[syncWithMeili] Error adding document to Meili', error); |
| } |
| } |
|
|
| |
| static async setMeiliIndexSettings(settings) { |
| return await index.updateSettings(settings); |
| } |
|
|
| |
| static async meiliSearch(q, params, populate) { |
| const data = await index.search(q, params); |
|
|
| |
| if (populate) { |
| |
| const query = {}; |
| |
| query[primaryKey] = _.map(data.hits, (hit) => cleanUpPrimaryKeyValue(hit[primaryKey])); |
| |
| const hitsFromMongoose = await this.find( |
| query, |
| _.reduce( |
| this.schema.obj, |
| function (results, value, key) { |
| return { ...results, [key]: 1 }; |
| }, |
| { _id: 1, __v: 1 }, |
| ), |
| ).lean(); |
|
|
| |
| const populatedHits = data.hits.map(function (hit) { |
| const query = {}; |
| query[primaryKey] = hit[primaryKey]; |
| const originalHit = _.find(hitsFromMongoose, query); |
|
|
| return { |
| ...(originalHit ?? {}), |
| ...hit, |
| }; |
| }); |
| data.hits = populatedHits; |
| } |
|
|
| return data; |
| } |
|
|
| preprocessObjectForIndex() { |
| const object = _.pick(this.toJSON(), attributesToIndex); |
| |
| |
| if (object.conversationId && object.conversationId.includes('|')) { |
| object.conversationId = object.conversationId.replace(/\|/g, '--'); |
| } |
|
|
| if (object.content && Array.isArray(object.content)) { |
| object.text = object.content |
| .filter((item) => item.type === 'text' && item.text && item.text.value) |
| .map((item) => item.text.value) |
| .join(' '); |
| delete object.content; |
| } |
|
|
| return object; |
| } |
|
|
| |
| async addObjectToMeili() { |
| const object = this.preprocessObjectForIndex(); |
| try { |
| |
| await index.addDocuments([object]); |
| } catch (error) { |
| |
| |
| } |
|
|
| await this.collection.updateMany({ _id: this._id }, { $set: { _meiliIndex: true } }); |
| } |
|
|
| |
| async updateObjectToMeili() { |
| const object = _.pick(this.toJSON(), attributesToIndex); |
| await index.updateDocuments([object]); |
| } |
|
|
| |
| async deleteObjectFromMeili() { |
| await index.deleteDocument(this._id); |
| } |
|
|
| |
| postSaveHook() { |
| if (this._meiliIndex) { |
| this.updateObjectToMeili(); |
| } else { |
| this.addObjectToMeili(); |
| } |
| } |
|
|
| |
| postUpdateHook() { |
| if (this._meiliIndex) { |
| this.updateObjectToMeili(); |
| } |
| } |
|
|
| |
| postRemoveHook() { |
| if (this._meiliIndex) { |
| this.deleteObjectFromMeili(); |
| } |
| } |
| } |
|
|
| return MeiliMongooseModel; |
| }; |
|
|
| module.exports = function mongoMeili(schema, options) { |
| |
| validateOptions(options); |
|
|
| |
| schema.add({ |
| _meiliIndex: { |
| type: Boolean, |
| required: false, |
| select: false, |
| default: false, |
| }, |
| }); |
|
|
| const { host, apiKey, indexName, primaryKey } = options; |
|
|
| |
| const client = new MeiliSearch({ host, apiKey }); |
|
|
| |
| client.createIndex(indexName, { primaryKey }); |
|
|
| |
| const index = client.index(indexName); |
|
|
| const attributesToIndex = [ |
| ..._.reduce( |
| schema.obj, |
| function (results, value, key) { |
| return value.meiliIndex ? [...results, key] : results; |
| |
| }, |
| [], |
| ), |
| ]; |
|
|
| schema.loadClass(createMeiliMongooseModel({ index, indexName, client, attributesToIndex })); |
|
|
| |
| schema.post('save', function (doc) { |
| doc.postSaveHook(); |
| }); |
| schema.post('update', function (doc) { |
| doc.postUpdateHook(); |
| }); |
| schema.post('remove', function (doc) { |
| doc.postRemoveHook(); |
| }); |
|
|
| schema.pre('deleteMany', async function (next) { |
| if (!meiliEnabled) { |
| next(); |
| } |
|
|
| try { |
| if (Object.prototype.hasOwnProperty.call(schema.obj, 'messages')) { |
| const convoIndex = client.index('convos'); |
| const deletedConvos = await mongoose.model('Conversation').find(this._conditions).lean(); |
| let promises = []; |
| for (const convo of deletedConvos) { |
| promises.push(convoIndex.deleteDocument(convo.conversationId)); |
| } |
| await Promise.all(promises); |
| } |
|
|
| if (Object.prototype.hasOwnProperty.call(schema.obj, 'messageId')) { |
| const messageIndex = client.index('messages'); |
| const deletedMessages = await mongoose.model('Message').find(this._conditions).lean(); |
| let promises = []; |
| for (const message of deletedMessages) { |
| promises.push(messageIndex.deleteDocument(message.messageId)); |
| } |
| await Promise.all(promises); |
| } |
| return next(); |
| } catch (error) { |
| if (meiliEnabled) { |
| logger.error( |
| '[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion, next startup may be slow due to syncing', |
| error, |
| ); |
| } |
| return next(); |
| } |
| }); |
|
|
| schema.post('findOneAndUpdate', async function (doc) { |
| if (!meiliEnabled) { |
| return; |
| } |
|
|
| if (doc.unfinished) { |
| return; |
| } |
|
|
| let meiliDoc; |
| |
| if (doc.messages) { |
| try { |
| meiliDoc = await client.index('convos').getDocument(doc.conversationId); |
| } catch (error) { |
| logger.debug( |
| '[MeiliMongooseModel.findOneAndUpdate] Convo not found in MeiliSearch and will index ' + |
| doc.conversationId, |
| error, |
| ); |
| } |
| } |
|
|
| if (meiliDoc && meiliDoc.title === doc.title) { |
| return; |
| } |
|
|
| doc.postSaveHook(); |
| }); |
| }; |
|
|