mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-15 06:58:10 +01:00
* feat: replace unsupported MongoDB aggregation operators for FerretDB compatibility Replace $lookup, $unwind, $sample, $replaceRoot, and $addFields aggregation stages which are unsupported on FerretDB v2.x (postgres-documentdb backend). - Prompt.js: Replace $lookup/$unwind/$project pipelines with find().select().lean() + attachProductionPrompts() batch helper. Replace $group/$replaceRoot/$sample in getRandomPromptGroups with distinct() + Fisher-Yates shuffle. - Agent/Prompt migration scripts: Replace $lookup anti-join pattern with distinct() + $nin two-step queries for finding un-migrated resources. All replacement patterns verified against FerretDB v2.7.0. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: use $pullAll for simple array removals, fix memberIds type mismatches Replace $pull with $pullAll for exact-value scalar array removals. Both operators work on MongoDB and FerretDB, but $pullAll is more explicit for exact matching (no condition expressions). Fix critical type mismatch bugs where ObjectId values were used against String[] memberIds arrays in Group queries: - config/delete-user.js: use string uid instead of ObjectId user._id - e2e/setup/cleanupUser.ts: convert userId.toString() before query Harden PermissionService.bulkUpdateResourcePermissions abort handling to prevent crash when abortTransaction is called after commitTransaction. All changes verified against FerretDB v2.7.0 and MongoDB Memory Server. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden transaction support probe for FerretDB compatibility Commit the transaction before aborting in supportsTransactions probe, and wrap abortTransaction in try-catch to prevent crashes when abort is called after a successful commit (observed behavior on FerretDB). Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add FerretDB compatibility test suite, retry utilities, and CI config Add comprehensive FerretDB integration test suite covering: - $pullAll scalar array operations - $pull with subdocument conditions - $lookup replacement (find + manual join) - $sample replacement (distinct + Fisher-Yates) - $bit and $bitsAllSet operations - Migration anti-join pattern - Multi-tenancy (useDb, scaling, write amplification) - Sharding proof-of-concept - Production operations (backup/restore, schema migration, deadlock retry) Add production retryWithBackoff utility for deadlock recovery during concurrent index creation on FerretDB/DocumentDB backends. Add UserController.spec.js tests for deleteUserController (runs in CI). Configure jest and eslint to isolate FerretDB tests from CI pipelines: - packages/data-schemas/jest.config.mjs: ignore misc/ directory - eslint.config.mjs: ignore packages/data-schemas/misc/ Include Docker Compose config for local FerretDB v2.7 + postgres-documentdb, dedicated jest/tsconfig for the test files, and multi-tenancy findings doc. Co-authored-by: Cursor <cursoragent@cursor.com> * style: brace formatting in aclEntry.ts modifyPermissionBits Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: reorganize retry utilities and update imports - Moved retryWithBackoff utility to a new file `retry.ts` for better structure. - Updated imports in `orgOperations.ferretdb.spec.ts` to reflect the new location of retry utilities. - Removed old import statement for retryWithBackoff from index.ts to streamline exports. * test: add $pullAll coverage for ConversationTag and PermissionService Add integration tests for deleteConversationTag verifying $pullAll removes tags from conversations correctly, and for syncUserEntraGroupMemberships verifying $pullAll removes user from non-matching Entra groups while preserving local group membership. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
284 lines
8 KiB
JavaScript
284 lines
8 KiB
JavaScript
const { logger } = require('@librechat/data-schemas');
|
|
const { ConversationTag, Conversation } = require('~/db/models');
|
|
|
|
/**
|
|
* Retrieves all conversation tags for a user.
|
|
* @param {string} user - The user ID.
|
|
* @returns {Promise<Array>} An array of conversation tags.
|
|
*/
|
|
const getConversationTags = async (user) => {
|
|
try {
|
|
return await ConversationTag.find({ user }).sort({ position: 1 }).lean();
|
|
} catch (error) {
|
|
logger.error('[getConversationTags] Error getting conversation tags', error);
|
|
throw new Error('Error getting conversation tags');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Creates a new conversation tag.
|
|
* @param {string} user - The user ID.
|
|
* @param {Object} data - The tag data.
|
|
* @param {string} data.tag - The tag name.
|
|
* @param {string} [data.description] - The tag description.
|
|
* @param {boolean} [data.addToConversation] - Whether to add the tag to a conversation.
|
|
* @param {string} [data.conversationId] - The conversation ID to add the tag to.
|
|
* @returns {Promise<Object>} The created tag.
|
|
*/
|
|
const createConversationTag = async (user, data) => {
|
|
try {
|
|
const { tag, description, addToConversation, conversationId } = data;
|
|
|
|
const existingTag = await ConversationTag.findOne({ user, tag }).lean();
|
|
if (existingTag) {
|
|
return existingTag;
|
|
}
|
|
|
|
const maxPosition = await ConversationTag.findOne({ user }).sort('-position').lean();
|
|
const position = (maxPosition?.position || 0) + 1;
|
|
|
|
const newTag = await ConversationTag.findOneAndUpdate(
|
|
{ tag, user },
|
|
{
|
|
tag,
|
|
user,
|
|
count: addToConversation ? 1 : 0,
|
|
position,
|
|
description,
|
|
$setOnInsert: { createdAt: new Date() },
|
|
},
|
|
{
|
|
new: true,
|
|
upsert: true,
|
|
lean: true,
|
|
},
|
|
);
|
|
|
|
if (addToConversation && conversationId) {
|
|
await Conversation.findOneAndUpdate(
|
|
{ user, conversationId },
|
|
{ $addToSet: { tags: tag } },
|
|
{ new: true },
|
|
);
|
|
}
|
|
|
|
return newTag;
|
|
} catch (error) {
|
|
logger.error('[createConversationTag] Error creating conversation tag', error);
|
|
throw new Error('Error creating conversation tag');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Updates an existing conversation tag.
|
|
* @param {string} user - The user ID.
|
|
* @param {string} oldTag - The current tag name.
|
|
* @param {Object} data - The updated tag data.
|
|
* @param {string} [data.tag] - The new tag name.
|
|
* @param {string} [data.description] - The updated description.
|
|
* @param {number} [data.position] - The new position.
|
|
* @returns {Promise<Object>} The updated tag.
|
|
*/
|
|
const updateConversationTag = async (user, oldTag, data) => {
|
|
try {
|
|
const { tag: newTag, description, position } = data;
|
|
|
|
const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean();
|
|
if (!existingTag) {
|
|
return null;
|
|
}
|
|
|
|
if (newTag && newTag !== oldTag) {
|
|
const tagAlreadyExists = await ConversationTag.findOne({ user, tag: newTag }).lean();
|
|
if (tagAlreadyExists) {
|
|
throw new Error('Tag already exists');
|
|
}
|
|
|
|
await Conversation.updateMany({ user, tags: oldTag }, { $set: { 'tags.$': newTag } });
|
|
}
|
|
|
|
const updateData = {};
|
|
if (newTag) {
|
|
updateData.tag = newTag;
|
|
}
|
|
if (description !== undefined) {
|
|
updateData.description = description;
|
|
}
|
|
if (position !== undefined) {
|
|
await adjustPositions(user, existingTag.position, position);
|
|
updateData.position = position;
|
|
}
|
|
|
|
return await ConversationTag.findOneAndUpdate({ user, tag: oldTag }, updateData, {
|
|
new: true,
|
|
lean: true,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[updateConversationTag] Error updating conversation tag', error);
|
|
throw new Error('Error updating conversation tag');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Adjusts positions of tags when a tag's position is changed.
|
|
* @param {string} user - The user ID.
|
|
* @param {number} oldPosition - The old position of the tag.
|
|
* @param {number} newPosition - The new position of the tag.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const adjustPositions = async (user, oldPosition, newPosition) => {
|
|
if (oldPosition === newPosition) {
|
|
return;
|
|
}
|
|
|
|
const update = oldPosition < newPosition ? { $inc: { position: -1 } } : { $inc: { position: 1 } };
|
|
const position =
|
|
oldPosition < newPosition
|
|
? {
|
|
$gt: Math.min(oldPosition, newPosition),
|
|
$lte: Math.max(oldPosition, newPosition),
|
|
}
|
|
: {
|
|
$gte: Math.min(oldPosition, newPosition),
|
|
$lt: Math.max(oldPosition, newPosition),
|
|
};
|
|
|
|
await ConversationTag.updateMany(
|
|
{
|
|
user,
|
|
position,
|
|
},
|
|
update,
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Deletes a conversation tag.
|
|
* @param {string} user - The user ID.
|
|
* @param {string} tag - The tag to delete.
|
|
* @returns {Promise<Object>} The deleted tag.
|
|
*/
|
|
const deleteConversationTag = async (user, tag) => {
|
|
try {
|
|
const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean();
|
|
if (!deletedTag) {
|
|
return null;
|
|
}
|
|
|
|
await Conversation.updateMany({ user, tags: tag }, { $pullAll: { tags: [tag] } });
|
|
|
|
await ConversationTag.updateMany(
|
|
{ user, position: { $gt: deletedTag.position } },
|
|
{ $inc: { position: -1 } },
|
|
);
|
|
|
|
return deletedTag;
|
|
} catch (error) {
|
|
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
|
|
throw new Error('Error deleting conversation tag');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Updates tags for a specific conversation.
|
|
* @param {string} user - The user ID.
|
|
* @param {string} conversationId - The conversation ID.
|
|
* @param {string[]} tags - The new set of tags for the conversation.
|
|
* @returns {Promise<string[]>} The updated list of tags for the conversation.
|
|
*/
|
|
const updateTagsForConversation = async (user, conversationId, tags) => {
|
|
try {
|
|
const conversation = await Conversation.findOne({ user, conversationId }).lean();
|
|
if (!conversation) {
|
|
throw new Error('Conversation not found');
|
|
}
|
|
|
|
const oldTags = new Set(conversation.tags);
|
|
const newTags = new Set(tags);
|
|
|
|
const addedTags = [...newTags].filter((tag) => !oldTags.has(tag));
|
|
const removedTags = [...oldTags].filter((tag) => !newTags.has(tag));
|
|
|
|
const bulkOps = [];
|
|
|
|
for (const tag of addedTags) {
|
|
bulkOps.push({
|
|
updateOne: {
|
|
filter: { user, tag },
|
|
update: { $inc: { count: 1 } },
|
|
upsert: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
for (const tag of removedTags) {
|
|
bulkOps.push({
|
|
updateOne: {
|
|
filter: { user, tag },
|
|
update: { $inc: { count: -1 } },
|
|
},
|
|
});
|
|
}
|
|
|
|
if (bulkOps.length > 0) {
|
|
await ConversationTag.bulkWrite(bulkOps);
|
|
}
|
|
|
|
const updatedConversation = (
|
|
await Conversation.findOneAndUpdate(
|
|
{ user, conversationId },
|
|
{ $set: { tags: [...newTags] } },
|
|
{ new: true },
|
|
)
|
|
).toObject();
|
|
|
|
return updatedConversation.tags;
|
|
} catch (error) {
|
|
logger.error('[updateTagsForConversation] Error updating tags', error);
|
|
throw new Error('Error updating tags for conversation');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Increments tag counts for existing tags only.
|
|
* @param {string} user - The user ID.
|
|
* @param {string[]} tags - Array of tag names to increment
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const bulkIncrementTagCounts = async (user, tags) => {
|
|
if (!tags || tags.length === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const uniqueTags = [...new Set(tags.filter(Boolean))];
|
|
if (uniqueTags.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const bulkOps = uniqueTags.map((tag) => ({
|
|
updateOne: {
|
|
filter: { user, tag },
|
|
update: { $inc: { count: 1 } },
|
|
},
|
|
}));
|
|
|
|
const result = await ConversationTag.bulkWrite(bulkOps);
|
|
if (result && result.modifiedCount > 0) {
|
|
logger.debug(
|
|
`user: ${user} | Incremented tag counts - modified ${result.modifiedCount} tags`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[bulkIncrementTagCounts] Error incrementing tag counts', error);
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getConversationTags,
|
|
createConversationTag,
|
|
updateConversationTag,
|
|
deleteConversationTag,
|
|
bulkIncrementTagCounts,
|
|
updateTagsForConversation,
|
|
};
|