LibreChat/packages/data-schemas/misc/ferretdb/migrationAntiJoin.ferretdb.spec.ts
Danny Avila a06aba9ef4
🐘 feat: FerretDB Compatibility (#11769)
* 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>
2026-02-26 20:29:18 -05:00

362 lines
12 KiB
TypeScript

import mongoose, { Schema, Types } from 'mongoose';
/**
* Integration tests for migration anti-join → $nin replacement.
*
* The original migration scripts used a $lookup + $filter + $match({ $size: 0 })
* anti-join to find resources without ACL entries. FerretDB does not support
* $lookup, so this was replaced with a two-step pattern:
* 1. AclEntry.distinct('resourceId', { resourceType, principalType })
* 2. Model.find({ _id: { $nin: migratedIds }, ... })
*
* Run against FerretDB:
* FERRETDB_URI="mongodb://ferretdb:ferretdb@127.0.0.1:27020/migration_antijoin_test" npx jest migrationAntiJoin.ferretdb
*
* Run against MongoDB (for parity):
* FERRETDB_URI="mongodb://127.0.0.1:27017/migration_antijoin_test" npx jest migrationAntiJoin.ferretdb
*/
const FERRETDB_URI = process.env.FERRETDB_URI;
const describeIfFerretDB = FERRETDB_URI ? describe : describe.skip;
const agentSchema = new Schema({
id: { type: String, required: true },
name: { type: String, required: true },
author: { type: String },
isCollaborative: { type: Boolean, default: false },
});
const promptGroupSchema = new Schema({
name: { type: String, required: true },
author: { type: String },
authorName: { type: String },
category: { type: String },
});
const aclEntrySchema = new Schema(
{
principalType: { type: String, required: true },
principalId: { type: Schema.Types.Mixed },
resourceType: { type: String, required: true },
resourceId: { type: Schema.Types.ObjectId, required: true },
permBits: { type: Number, default: 1 },
roleId: { type: Schema.Types.ObjectId },
grantedBy: { type: Schema.Types.ObjectId },
grantedAt: { type: Date, default: Date.now },
},
{ timestamps: true },
);
type AgentDoc = mongoose.InferSchemaType<typeof agentSchema>;
type PromptGroupDoc = mongoose.InferSchemaType<typeof promptGroupSchema>;
type AclEntryDoc = mongoose.InferSchemaType<typeof aclEntrySchema>;
describeIfFerretDB('Migration anti-join → $nin - FerretDB compatibility', () => {
let Agent: mongoose.Model<AgentDoc>;
let PromptGroup: mongoose.Model<PromptGroupDoc>;
let AclEntry: mongoose.Model<AclEntryDoc>;
beforeAll(async () => {
await mongoose.connect(FERRETDB_URI as string);
Agent = mongoose.model('TestMigAgent', agentSchema);
PromptGroup = mongoose.model('TestMigPromptGroup', promptGroupSchema);
AclEntry = mongoose.model('TestMigAclEntry', aclEntrySchema);
});
afterAll(async () => {
await mongoose.connection.db?.dropDatabase();
await mongoose.disconnect();
});
beforeEach(async () => {
await Agent.deleteMany({});
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
describe('agent migration pattern', () => {
it('should return only agents WITHOUT user-type ACL entries', async () => {
const agent1 = await Agent.create({ id: 'agent_1', name: 'Migrated Agent', author: 'user1' });
const agent2 = await Agent.create({
id: 'agent_2',
name: 'Unmigrated Agent',
author: 'user2',
});
await Agent.create({ id: 'agent_3', name: 'Another Unmigrated', author: 'user3' });
await AclEntry.create({
principalType: 'user',
principalId: new Types.ObjectId(),
resourceType: 'agent',
resourceId: agent1._id,
});
await AclEntry.create({
principalType: 'public',
resourceType: 'agent',
resourceId: agent2._id,
});
const migratedIds = await AclEntry.distinct('resourceId', {
resourceType: 'agent',
principalType: 'user',
});
const toMigrate = await Agent.find({
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
})
.select('_id id name author isCollaborative')
.lean();
expect(toMigrate).toHaveLength(2);
const names = toMigrate.map((a: Record<string, unknown>) => a.name).sort();
expect(names).toEqual(['Another Unmigrated', 'Unmigrated Agent']);
});
it('should exclude agents without an author', async () => {
await Agent.create({ id: 'agent_no_author', name: 'No Author' });
await Agent.create({ id: 'agent_null_author', name: 'Null Author', author: null });
await Agent.create({ id: 'agent_with_author', name: 'Has Author', author: 'user1' });
const migratedIds = await AclEntry.distinct('resourceId', {
resourceType: 'agent',
principalType: 'user',
});
const toMigrate = await Agent.find({
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
})
.select('_id id name author')
.lean();
expect(toMigrate).toHaveLength(1);
expect((toMigrate[0] as Record<string, unknown>).name).toBe('Has Author');
});
it('should return empty array when all agents are migrated', async () => {
const agent1 = await Agent.create({ id: 'a1', name: 'Agent 1', author: 'user1' });
const agent2 = await Agent.create({ id: 'a2', name: 'Agent 2', author: 'user2' });
await AclEntry.create([
{
principalType: 'user',
principalId: new Types.ObjectId(),
resourceType: 'agent',
resourceId: agent1._id,
},
{
principalType: 'user',
principalId: new Types.ObjectId(),
resourceType: 'agent',
resourceId: agent2._id,
},
]);
const migratedIds = await AclEntry.distinct('resourceId', {
resourceType: 'agent',
principalType: 'user',
});
const toMigrate = await Agent.find({
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
}).lean();
expect(toMigrate).toHaveLength(0);
});
it('should not be confused by ACL entries for a different resourceType', async () => {
const agent = await Agent.create({ id: 'a1', name: 'Agent', author: 'user1' });
await AclEntry.create({
principalType: 'user',
principalId: new Types.ObjectId(),
resourceType: 'promptGroup',
resourceId: agent._id,
});
const migratedIds = await AclEntry.distinct('resourceId', {
resourceType: 'agent',
principalType: 'user',
});
const toMigrate = await Agent.find({
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
}).lean();
expect(toMigrate).toHaveLength(1);
expect((toMigrate[0] as Record<string, unknown>).name).toBe('Agent');
});
it('should return correct projected fields', async () => {
await Agent.create({
id: 'proj_agent',
name: 'Field Test',
author: 'user1',
isCollaborative: true,
});
const migratedIds = await AclEntry.distinct('resourceId', {
resourceType: 'agent',
principalType: 'user',
});
const toMigrate = await Agent.find({
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
})
.select('_id id name author isCollaborative')
.lean();
expect(toMigrate).toHaveLength(1);
const agent = toMigrate[0] as Record<string, unknown>;
expect(agent).toHaveProperty('_id');
expect(agent).toHaveProperty('id', 'proj_agent');
expect(agent).toHaveProperty('name', 'Field Test');
expect(agent).toHaveProperty('author', 'user1');
expect(agent).toHaveProperty('isCollaborative', true);
});
});
describe('promptGroup migration pattern', () => {
it('should return only prompt groups WITHOUT user-type ACL entries', async () => {
const pg1 = await PromptGroup.create({
name: 'Migrated PG',
author: 'user1',
category: 'code',
});
await PromptGroup.create({ name: 'Unmigrated PG', author: 'user2', category: 'writing' });
await AclEntry.create({
principalType: 'user',
principalId: new Types.ObjectId(),
resourceType: 'promptGroup',
resourceId: pg1._id,
});
const migratedIds = await AclEntry.distinct('resourceId', {
resourceType: 'promptGroup',
principalType: 'user',
});
const toMigrate = await PromptGroup.find({
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
})
.select('_id name author authorName category')
.lean();
expect(toMigrate).toHaveLength(1);
expect((toMigrate[0] as Record<string, unknown>).name).toBe('Unmigrated PG');
});
it('should return correct projected fields for prompt groups', async () => {
await PromptGroup.create({
name: 'PG Fields',
author: 'user1',
authorName: 'Test User',
category: 'marketing',
});
const migratedIds = await AclEntry.distinct('resourceId', {
resourceType: 'promptGroup',
principalType: 'user',
});
const toMigrate = await PromptGroup.find({
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
})
.select('_id name author authorName category')
.lean();
expect(toMigrate).toHaveLength(1);
const pg = toMigrate[0] as Record<string, unknown>;
expect(pg).toHaveProperty('_id');
expect(pg).toHaveProperty('name', 'PG Fields');
expect(pg).toHaveProperty('author', 'user1');
expect(pg).toHaveProperty('authorName', 'Test User');
expect(pg).toHaveProperty('category', 'marketing');
});
});
describe('cross-resource isolation', () => {
it('should independently track agent and promptGroup migrations', async () => {
const agent = await Agent.create({
id: 'iso_agent',
name: 'Isolated Agent',
author: 'user1',
});
await PromptGroup.create({ name: 'Isolated PG', author: 'user2' });
await AclEntry.create({
principalType: 'user',
principalId: new Types.ObjectId(),
resourceType: 'agent',
resourceId: agent._id,
});
const migratedAgentIds = await AclEntry.distinct('resourceId', {
resourceType: 'agent',
principalType: 'user',
});
const migratedPGIds = await AclEntry.distinct('resourceId', {
resourceType: 'promptGroup',
principalType: 'user',
});
const agentsToMigrate = await Agent.find({
_id: { $nin: migratedAgentIds },
author: { $exists: true, $ne: null },
}).lean();
const pgsToMigrate = await PromptGroup.find({
_id: { $nin: migratedPGIds },
author: { $exists: true, $ne: null },
}).lean();
expect(agentsToMigrate).toHaveLength(0);
expect(pgsToMigrate).toHaveLength(1);
});
});
describe('scale behavior', () => {
it('should correctly handle many resources with partial migration', async () => {
const agents = [];
for (let i = 0; i < 20; i++) {
agents.push({ id: `agent_${i}`, name: `Agent ${i}`, author: `user_${i}` });
}
const created = await Agent.insertMany(agents);
const migrateEvens = created
.filter((_, i) => i % 2 === 0)
.map((a) => ({
principalType: 'user',
principalId: new Types.ObjectId(),
resourceType: 'agent',
resourceId: a._id,
}));
await AclEntry.insertMany(migrateEvens);
const migratedIds = await AclEntry.distinct('resourceId', {
resourceType: 'agent',
principalType: 'user',
});
const toMigrate = await Agent.find({
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
}).lean();
expect(toMigrate).toHaveLength(10);
const indices = toMigrate
.map((a) => parseInt(String(a.name).replace('Agent ', ''), 10))
.sort((a, b) => a - b);
expect(indices).toEqual([1, 3, 5, 7, 9, 11, 13, 15, 17, 19]);
});
});
});