mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-20 09:24: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>
297 lines
9.9 KiB
TypeScript
297 lines
9.9 KiB
TypeScript
import mongoose, { Schema, Types } from 'mongoose';
|
|
|
|
/**
|
|
* Integration tests for $pullAll compatibility with FerretDB.
|
|
*
|
|
* These tests verify that the $pull → $pullAll migration works
|
|
* identically on both MongoDB and FerretDB by running against
|
|
* a real database specified via FERRETDB_URI env var.
|
|
*
|
|
* Run against FerretDB:
|
|
* FERRETDB_URI="mongodb://ferretdb:ferretdb@127.0.0.1:27020/pullall_test" npx jest pullAll.ferretdb
|
|
*
|
|
* Run against MongoDB (for parity):
|
|
* FERRETDB_URI="mongodb://127.0.0.1:27017/pullall_test" npx jest pullAll.ferretdb
|
|
*/
|
|
|
|
const FERRETDB_URI = process.env.FERRETDB_URI;
|
|
|
|
const describeIfFerretDB = FERRETDB_URI ? describe : describe.skip;
|
|
|
|
const groupSchema = new Schema({
|
|
name: { type: String, required: true },
|
|
memberIds: [{ type: String }],
|
|
});
|
|
|
|
const conversationSchema = new Schema({
|
|
conversationId: { type: String, required: true, unique: true },
|
|
user: { type: String },
|
|
tags: { type: [String], default: [] },
|
|
});
|
|
|
|
const projectSchema = new Schema({
|
|
name: { type: String, required: true },
|
|
promptGroupIds: { type: [Schema.Types.ObjectId], default: [] },
|
|
agentIds: { type: [String], default: [] },
|
|
});
|
|
|
|
const agentSchema = new Schema({
|
|
name: { type: String, required: true },
|
|
projectIds: { type: [String], default: [] },
|
|
tool_resources: { type: Schema.Types.Mixed, default: {} },
|
|
});
|
|
|
|
describeIfFerretDB('$pullAll FerretDB compatibility', () => {
|
|
let Group: mongoose.Model<unknown>;
|
|
let Conversation: mongoose.Model<unknown>;
|
|
let Project: mongoose.Model<unknown>;
|
|
let Agent: mongoose.Model<unknown>;
|
|
|
|
beforeAll(async () => {
|
|
await mongoose.connect(FERRETDB_URI as string);
|
|
|
|
Group = mongoose.models.FDBGroup || mongoose.model('FDBGroup', groupSchema);
|
|
Conversation =
|
|
mongoose.models.FDBConversation || mongoose.model('FDBConversation', conversationSchema);
|
|
Project = mongoose.models.FDBProject || mongoose.model('FDBProject', projectSchema);
|
|
Agent = mongoose.models.FDBAgent || mongoose.model('FDBAgent', agentSchema);
|
|
|
|
await Group.createCollection();
|
|
await Conversation.createCollection();
|
|
await Project.createCollection();
|
|
await Agent.createCollection();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await mongoose.connection.dropDatabase();
|
|
await mongoose.disconnect();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await Group.deleteMany({});
|
|
await Conversation.deleteMany({});
|
|
await Project.deleteMany({});
|
|
await Agent.deleteMany({});
|
|
});
|
|
|
|
describe('scalar $pullAll (single value wrapped in array)', () => {
|
|
it('should remove a single memberId from a group', async () => {
|
|
const userId = new Types.ObjectId().toString();
|
|
const otherUserId = new Types.ObjectId().toString();
|
|
|
|
await Group.create({
|
|
name: 'Test Group',
|
|
memberIds: [userId, otherUserId],
|
|
});
|
|
|
|
await Group.updateMany({ memberIds: userId }, { $pullAll: { memberIds: [userId] } });
|
|
|
|
const updated = await Group.findOne({ name: 'Test Group' }).lean();
|
|
const doc = updated as Record<string, unknown>;
|
|
expect(doc.memberIds).toEqual([otherUserId]);
|
|
});
|
|
|
|
it('should remove a memberId from multiple groups at once', async () => {
|
|
const userId = new Types.ObjectId().toString();
|
|
|
|
await Group.create([
|
|
{ name: 'Group A', memberIds: [userId, 'other-1'] },
|
|
{ name: 'Group B', memberIds: [userId, 'other-2'] },
|
|
{ name: 'Group C', memberIds: ['other-3'] },
|
|
]);
|
|
|
|
await Group.updateMany({ memberIds: userId }, { $pullAll: { memberIds: [userId] } });
|
|
|
|
const groups = await Group.find({}).sort({ name: 1 }).lean();
|
|
const docs = groups as Array<Record<string, unknown>>;
|
|
expect(docs[0].memberIds).toEqual(['other-1']);
|
|
expect(docs[1].memberIds).toEqual(['other-2']);
|
|
expect(docs[2].memberIds).toEqual(['other-3']);
|
|
});
|
|
|
|
it('should remove a tag from conversations', async () => {
|
|
const user = 'user-123';
|
|
const tag = 'important';
|
|
|
|
await Conversation.create([
|
|
{ conversationId: 'conv-1', user, tags: [tag, 'other'] },
|
|
{ conversationId: 'conv-2', user, tags: [tag] },
|
|
{ conversationId: 'conv-3', user, tags: ['other'] },
|
|
]);
|
|
|
|
await Conversation.updateMany({ user, tags: tag }, { $pullAll: { tags: [tag] } });
|
|
|
|
const convos = await Conversation.find({}).sort({ conversationId: 1 }).lean();
|
|
const docs = convos as Array<Record<string, unknown>>;
|
|
expect(docs[0].tags).toEqual(['other']);
|
|
expect(docs[1].tags).toEqual([]);
|
|
expect(docs[2].tags).toEqual(['other']);
|
|
});
|
|
|
|
it('should remove a single agentId from all projects', async () => {
|
|
const agentId = 'agent-to-remove';
|
|
|
|
await Project.create([
|
|
{ name: 'Proj A', agentIds: [agentId, 'agent-keep'] },
|
|
{ name: 'Proj B', agentIds: ['agent-keep'] },
|
|
]);
|
|
|
|
await Project.updateMany({}, { $pullAll: { agentIds: [agentId] } });
|
|
|
|
const projects = await Project.find({}).sort({ name: 1 }).lean();
|
|
const docs = projects as Array<Record<string, unknown>>;
|
|
expect(docs[0].agentIds).toEqual(['agent-keep']);
|
|
expect(docs[1].agentIds).toEqual(['agent-keep']);
|
|
});
|
|
|
|
it('should be a no-op when the value does not exist in the array', async () => {
|
|
await Group.create({ name: 'Stable Group', memberIds: ['a', 'b'] });
|
|
|
|
await Group.updateMany(
|
|
{ memberIds: 'nonexistent' },
|
|
{ $pullAll: { memberIds: ['nonexistent'] } },
|
|
);
|
|
|
|
const group = await Group.findOne({ name: 'Stable Group' }).lean();
|
|
const doc = group as Record<string, unknown>;
|
|
expect(doc.memberIds).toEqual(['a', 'b']);
|
|
});
|
|
});
|
|
|
|
describe('multi-value $pullAll (replacing $pull + $in)', () => {
|
|
it('should remove multiple promptGroupIds from a project', async () => {
|
|
const ids = [new Types.ObjectId(), new Types.ObjectId(), new Types.ObjectId()];
|
|
|
|
await Project.create({
|
|
name: 'Test Project',
|
|
promptGroupIds: ids,
|
|
});
|
|
|
|
const toRemove = [ids[0], ids[2]];
|
|
await Project.findOneAndUpdate(
|
|
{ name: 'Test Project' },
|
|
{ $pullAll: { promptGroupIds: toRemove } },
|
|
{ new: true },
|
|
);
|
|
|
|
const updated = await Project.findOne({ name: 'Test Project' }).lean();
|
|
const doc = updated as Record<string, unknown>;
|
|
const remaining = (doc.promptGroupIds as Types.ObjectId[]).map((id) => id.toString());
|
|
expect(remaining).toEqual([ids[1].toString()]);
|
|
});
|
|
|
|
it('should remove multiple agentIds from a project', async () => {
|
|
await Project.create({
|
|
name: 'Agent Project',
|
|
agentIds: ['a1', 'a2', 'a3', 'a4'],
|
|
});
|
|
|
|
await Project.findOneAndUpdate(
|
|
{ name: 'Agent Project' },
|
|
{ $pullAll: { agentIds: ['a1', 'a3'] } },
|
|
{ new: true },
|
|
);
|
|
|
|
const updated = await Project.findOne({ name: 'Agent Project' }).lean();
|
|
const doc = updated as Record<string, unknown>;
|
|
expect(doc.agentIds).toEqual(['a2', 'a4']);
|
|
});
|
|
|
|
it('should remove projectIds from an agent', async () => {
|
|
await Agent.create({
|
|
name: 'Test Agent',
|
|
projectIds: ['p1', 'p2', 'p3'],
|
|
});
|
|
|
|
await Agent.findOneAndUpdate(
|
|
{ name: 'Test Agent' },
|
|
{ $pullAll: { projectIds: ['p1', 'p3'] } },
|
|
{ new: true },
|
|
);
|
|
|
|
const updated = await Agent.findOne({ name: 'Test Agent' }).lean();
|
|
const doc = updated as Record<string, unknown>;
|
|
expect(doc.projectIds).toEqual(['p2']);
|
|
});
|
|
|
|
it('should handle removing from nested dynamic paths (tool_resources)', async () => {
|
|
await Agent.create({
|
|
name: 'Resource Agent',
|
|
tool_resources: {
|
|
code_interpreter: { file_ids: ['f1', 'f2', 'f3'] },
|
|
file_search: { file_ids: ['f4', 'f5'] },
|
|
},
|
|
});
|
|
|
|
const pullAllOps: Record<string, string[]> = {};
|
|
const filesByResource = {
|
|
code_interpreter: ['f1', 'f3'],
|
|
file_search: ['f5'],
|
|
};
|
|
|
|
for (const [resource, fileIds] of Object.entries(filesByResource)) {
|
|
pullAllOps[`tool_resources.${resource}.file_ids`] = fileIds;
|
|
}
|
|
|
|
await Agent.findOneAndUpdate(
|
|
{ name: 'Resource Agent' },
|
|
{ $pullAll: pullAllOps },
|
|
{ new: true },
|
|
);
|
|
|
|
const updated = await Agent.findOne({ name: 'Resource Agent' }).lean();
|
|
const doc = updated as unknown as Record<string, { [key: string]: { file_ids: string[] } }>;
|
|
expect(doc.tool_resources.code_interpreter.file_ids).toEqual(['f2']);
|
|
expect(doc.tool_resources.file_search.file_ids).toEqual(['f4']);
|
|
});
|
|
|
|
it('should handle empty array (no-op)', async () => {
|
|
await Project.create({
|
|
name: 'Unchanged',
|
|
agentIds: ['a1', 'a2'],
|
|
});
|
|
|
|
await Project.findOneAndUpdate(
|
|
{ name: 'Unchanged' },
|
|
{ $pullAll: { agentIds: [] } },
|
|
{ new: true },
|
|
);
|
|
|
|
const updated = await Project.findOne({ name: 'Unchanged' }).lean();
|
|
const doc = updated as Record<string, unknown>;
|
|
expect(doc.agentIds).toEqual(['a1', 'a2']);
|
|
});
|
|
|
|
it('should handle values not present in the array', async () => {
|
|
await Project.create({
|
|
name: 'Partial',
|
|
agentIds: ['a1', 'a2'],
|
|
});
|
|
|
|
await Project.findOneAndUpdate(
|
|
{ name: 'Partial' },
|
|
{ $pullAll: { agentIds: ['a1', 'nonexistent'] } },
|
|
{ new: true },
|
|
);
|
|
|
|
const updated = await Project.findOne({ name: 'Partial' }).lean();
|
|
const doc = updated as Record<string, unknown>;
|
|
expect(doc.agentIds).toEqual(['a2']);
|
|
});
|
|
});
|
|
|
|
describe('duplicate handling', () => {
|
|
it('should remove all occurrences of a duplicated value', async () => {
|
|
await Group.create({
|
|
name: 'Dupes Group',
|
|
memberIds: ['a', 'b', 'a', 'c', 'a'],
|
|
});
|
|
|
|
await Group.updateMany({ name: 'Dupes Group' }, { $pullAll: { memberIds: ['a'] } });
|
|
|
|
const updated = await Group.findOne({ name: 'Dupes Group' }).lean();
|
|
const doc = updated as Record<string, unknown>;
|
|
expect(doc.memberIds).toEqual(['b', 'c']);
|
|
});
|
|
});
|
|
});
|