import mongoose, { Schema, type Connection, type Model } from 'mongoose'; import { actionSchema, agentSchema, agentApiKeySchema, agentCategorySchema, assistantSchema, balanceSchema, bannerSchema, conversationTagSchema, convoSchema, fileSchema, keySchema, messageSchema, pluginAuthSchema, presetSchema, projectSchema, promptSchema, promptGroupSchema, roleSchema, sessionSchema, shareSchema, tokenSchema, toolCallSchema, transactionSchema, userSchema, memorySchema, groupSchema, } from '~/schema'; import accessRoleSchema from '~/schema/accessRole'; import mcpServerSchema from '~/schema/mcpServer'; import aclEntrySchema from '~/schema/aclEntry'; import { initializeOrgCollections, createIndexesWithRetry, retryWithBackoff } from '~/utils/retry'; /** * Production operations tests for FerretDB multi-tenancy: * 1. Retry utility under simulated and real deadlock conditions * 2. Programmatic per-org backup/restore (driver-level, no mongodump) * 3. Schema migration across existing org databases * * Run: * FERRETDB_URI="mongodb://ferretdb:ferretdb@127.0.0.1:27020/ops_test" \ * npx jest orgOperations.ferretdb --testTimeout=300000 */ const FERRETDB_URI = process.env.FERRETDB_URI; const describeIfFerretDB = FERRETDB_URI ? describe : describe.skip; const DB_PREFIX = 'ops_test_'; const MODEL_SCHEMAS: Record = { User: userSchema, Token: tokenSchema, Session: sessionSchema, Balance: balanceSchema, Conversation: convoSchema, Message: messageSchema, Agent: agentSchema, AgentApiKey: agentApiKeySchema, AgentCategory: agentCategorySchema, MCPServer: mcpServerSchema, Role: roleSchema, Action: actionSchema, Assistant: assistantSchema, File: fileSchema, Banner: bannerSchema, Project: projectSchema, Key: keySchema, PluginAuth: pluginAuthSchema, Transaction: transactionSchema, Preset: presetSchema, Prompt: promptSchema, PromptGroup: promptGroupSchema, ConversationTag: conversationTagSchema, SharedLink: shareSchema, ToolCall: toolCallSchema, MemoryEntry: memorySchema, AccessRole: accessRoleSchema, AclEntry: aclEntrySchema, Group: groupSchema, }; const MODEL_COUNT = Object.keys(MODEL_SCHEMAS).length; function registerModels(conn: Connection): Record> { const models: Record> = {}; for (const [name, schema] of Object.entries(MODEL_SCHEMAS)) { models[name] = conn.models[name] || conn.model(name, schema); } return models; } // ─── BACKUP/RESTORE UTILITIES ─────────────────────────────────────────────── interface OrgBackup { orgId: string; timestamp: Date; collections: Record; } /** Dump all collections from an org database to an in-memory structure */ async function backupOrg(conn: Connection, orgId: string): Promise { const collectionNames = (await conn.db!.listCollections().toArray()).map((c) => c.name); const collections: Record = {}; for (const name of collectionNames) { if (name.startsWith('system.')) { continue; } const docs = await conn.db!.collection(name).find({}).toArray(); collections[name] = docs; } return { orgId, timestamp: new Date(), collections }; } /** Restore collections from a backup into a target connection */ async function restoreOrg( conn: Connection, backup: OrgBackup, ): Promise<{ collectionsRestored: number; docsRestored: number }> { let docsRestored = 0; for (const [name, docs] of Object.entries(backup.collections)) { if (docs.length === 0) { continue; } const collection = conn.db!.collection(name); await collection.insertMany(docs as Array>); docsRestored += docs.length; } return { collectionsRestored: Object.keys(backup.collections).length, docsRestored }; } // ─── MIGRATION UTILITIES ──────────────────────────────────────────────────── interface MigrationResult { orgId: string; newCollections: string[]; indexResults: Array<{ model: string; created: boolean; ms: number }>; totalMs: number; } /** Migrate a single org: ensure all collections exist and all indexes are current */ async function migrateOrg( conn: Connection, orgId: string, schemas: Record, ): Promise { const t0 = Date.now(); const models = registerModels(conn); const existingCollections = new Set( (await conn.db!.listCollections().toArray()).map((c) => c.name), ); const newCollections: string[] = []; const indexResults: Array<{ model: string; created: boolean; ms: number }> = []; for (const [name, model] of Object.entries(models)) { const collName = model.collection.collectionName; const isNew = !existingCollections.has(collName); if (isNew) { newCollections.push(name); } const mt0 = Date.now(); await model.createCollection(); await createIndexesWithRetry(model); indexResults.push({ model: name, created: isNew, ms: Date.now() - mt0 }); } return { orgId, newCollections, indexResults, totalMs: Date.now() - t0 }; } /** Migrate all orgs in sequence with progress reporting */ async function migrateAllOrgs( baseConn: Connection, orgIds: string[], schemas: Record, onProgress?: (completed: number, total: number, result: MigrationResult) => void, ): Promise { const results: MigrationResult[] = []; for (let i = 0; i < orgIds.length; i++) { const orgId = orgIds[i]; const conn = baseConn.useDb(`${DB_PREFIX}org_${orgId}`, { useCache: true }); const result = await migrateOrg(conn, orgId, schemas); results.push(result); if (onProgress) { onProgress(i + 1, orgIds.length, result); } } return results; } // ─── TESTS ────────────────────────────────────────────────────────────────── describeIfFerretDB('Org Operations (Production)', () => { const createdDbs: string[] = []; let baseConn: Connection; beforeAll(async () => { baseConn = await mongoose.createConnection(FERRETDB_URI as string).asPromise(); }); afterAll(async () => { for (const db of createdDbs) { try { await baseConn.useDb(db, { useCache: false }).dropDatabase(); } catch { /* best-effort */ } } await baseConn.close(); }, 120_000); // ─── RETRY UTILITY ────────────────────────────────────────────────────── describe('retryWithBackoff', () => { it('succeeds on first attempt when no error', async () => { let calls = 0; const result = await retryWithBackoff(async () => { calls++; return 'ok'; }, 'test-op'); expect(result).toBe('ok'); expect(calls).toBe(1); }); it('retries on deadlock error and eventually succeeds', async () => { let calls = 0; const result = await retryWithBackoff( async () => { calls++; if (calls < 3) { throw new Error('deadlock detected'); } return 'recovered'; }, 'deadlock-test', { baseDelayMs: 10, jitter: false }, ); expect(result).toBe('recovered'); expect(calls).toBe(3); }); it('does not retry on non-retryable errors', async () => { let calls = 0; await expect( retryWithBackoff( async () => { calls++; throw new Error('validation failed'); }, 'non-retryable', { baseDelayMs: 10 }, ), ).rejects.toThrow('validation failed'); expect(calls).toBe(1); }); it('exhausts max attempts and throws', async () => { let calls = 0; await expect( retryWithBackoff( async () => { calls++; throw new Error('deadlock detected'); }, 'exhausted', { maxAttempts: 3, baseDelayMs: 10, jitter: false }, ), ).rejects.toThrow('deadlock'); expect(calls).toBe(3); }); it('respects maxDelayMs cap', async () => { const delays: number[] = []; let calls = 0; await retryWithBackoff( async () => { calls++; if (calls < 4) { throw new Error('deadlock detected'); } return 'ok'; }, 'delay-cap', { baseDelayMs: 100, maxDelayMs: 250, jitter: false, onRetry: (_err, _attempt, delay) => delays.push(delay), }, ); expect(delays[0]).toBe(100); expect(delays[1]).toBe(200); expect(delays[2]).toBe(250); }); }); // ─── REAL DEADLOCK RETRY ──────────────────────────────────────────────── describe('initializeOrgCollections with retry', () => { it('provisions 5 orgs sequentially using the production utility', async () => { const orgIds = ['retry_1', 'retry_2', 'retry_3', 'retry_4', 'retry_5']; const results: Array<{ orgId: string; ms: number; models: number }> = []; for (const orgId of orgIds) { const dbName = `${DB_PREFIX}org_${orgId}`; createdDbs.push(dbName); const conn = baseConn.useDb(dbName, { useCache: true }); const models = registerModels(conn); const { totalMs } = await initializeOrgCollections(models, { baseDelayMs: 50, maxAttempts: 5, }); results.push({ orgId, ms: totalMs, models: Object.keys(models).length }); } const totalMs = results.reduce((s, r) => s + r.ms, 0); console.log(`[Retry] 5 orgs provisioned in ${totalMs}ms:`); for (const r of results) { console.log(` ${r.orgId}: ${r.ms}ms (${r.models} models)`); } expect(results.every((r) => r.models === MODEL_COUNT)).toBe(true); }, 120_000); }); // ─── BACKUP/RESTORE ───────────────────────────────────────────────────── describe('per-org backup and restore', () => { const sourceOrg = 'backup_src'; const targetOrg = 'backup_dst'; beforeAll(async () => { const srcDb = `${DB_PREFIX}org_${sourceOrg}`; createdDbs.push(srcDb, `${DB_PREFIX}org_${targetOrg}`); const srcConn = baseConn.useDb(srcDb, { useCache: true }); const models = registerModels(srcConn); await initializeOrgCollections(models); await models.User.create([ { name: 'Alice', email: 'alice@backup.test', username: 'alice' }, { name: 'Bob', email: 'bob@backup.test', username: 'bob' }, { name: 'Charlie', email: 'charlie@backup.test', username: 'charlie' }, ]); await models.Conversation.create([ { conversationId: 'conv_1', user: 'alice_id', title: 'Test conversation 1', endpoint: 'openAI', model: 'gpt-4', }, { conversationId: 'conv_2', user: 'bob_id', title: 'Test conversation 2', endpoint: 'openAI', model: 'gpt-4', }, ]); await models.Message.create([ { messageId: 'msg_1', conversationId: 'conv_1', user: 'alice_id', sender: 'user', text: 'Hello world', isCreatedByUser: true, }, { messageId: 'msg_2', conversationId: 'conv_1', user: 'alice_id', sender: 'GPT-4', text: 'Hi there!', isCreatedByUser: false, }, ]); const agentId = new mongoose.Types.ObjectId(); await models.Agent.create({ id: `agent_${agentId}`, name: 'Test Agent', author: new mongoose.Types.ObjectId(), description: 'A test agent for backup', provider: 'openAI', model: 'gpt-4', }); }, 60_000); it('backs up all collections from the source org', async () => { const srcConn = baseConn.useDb(`${DB_PREFIX}org_${sourceOrg}`, { useCache: true }); const backup = await backupOrg(srcConn, sourceOrg); console.log(`[Backup] ${sourceOrg}:`); console.log(` Timestamp: ${backup.timestamp.toISOString()}`); console.log(` Collections: ${Object.keys(backup.collections).length}`); let totalDocs = 0; for (const [name, docs] of Object.entries(backup.collections)) { if (docs.length > 0) { console.log(` ${name}: ${docs.length} docs`); totalDocs += docs.length; } } console.log(` Total documents: ${totalDocs}`); expect(Object.keys(backup.collections).length).toBeGreaterThanOrEqual(4); expect(backup.collections['users']?.length).toBe(3); expect(backup.collections['conversations']?.length).toBe(2); expect(backup.collections['messages']?.length).toBe(2); }, 30_000); it('restores backup to a fresh org database', async () => { const srcConn = baseConn.useDb(`${DB_PREFIX}org_${sourceOrg}`, { useCache: true }); const backup = await backupOrg(srcConn, sourceOrg); const dstConn = baseConn.useDb(`${DB_PREFIX}org_${targetOrg}`, { useCache: true }); const dstModels = registerModels(dstConn); await initializeOrgCollections(dstModels); const { collectionsRestored, docsRestored } = await restoreOrg(dstConn, backup); console.log( `[Restore] ${targetOrg}: ${collectionsRestored} collections, ${docsRestored} docs`, ); expect(docsRestored).toBeGreaterThanOrEqual(7); }, 60_000); it('verifies restored data matches source exactly', async () => { const srcConn = baseConn.useDb(`${DB_PREFIX}org_${sourceOrg}`, { useCache: true }); const dstConn = baseConn.useDb(`${DB_PREFIX}org_${targetOrg}`, { useCache: true }); const srcUsers = await srcConn.db!.collection('users').find({}).sort({ email: 1 }).toArray(); const dstUsers = await dstConn.db!.collection('users').find({}).sort({ email: 1 }).toArray(); expect(dstUsers.length).toBe(srcUsers.length); for (let i = 0; i < srcUsers.length; i++) { expect(dstUsers[i].name).toBe(srcUsers[i].name); expect(dstUsers[i].email).toBe(srcUsers[i].email); expect(dstUsers[i]._id.toString()).toBe(srcUsers[i]._id.toString()); } const srcMsgs = await srcConn .db!.collection('messages') .find({}) .sort({ messageId: 1 }) .toArray(); const dstMsgs = await dstConn .db!.collection('messages') .find({}) .sort({ messageId: 1 }) .toArray(); expect(dstMsgs.length).toBe(srcMsgs.length); for (let i = 0; i < srcMsgs.length; i++) { expect(dstMsgs[i].messageId).toBe(srcMsgs[i].messageId); expect(dstMsgs[i].text).toBe(srcMsgs[i].text); expect(dstMsgs[i]._id.toString()).toBe(srcMsgs[i]._id.toString()); } const srcConvos = await srcConn .db!.collection('conversations') .find({}) .sort({ conversationId: 1 }) .toArray(); const dstConvos = await dstConn .db!.collection('conversations') .find({}) .sort({ conversationId: 1 }) .toArray(); expect(dstConvos.length).toBe(srcConvos.length); for (let i = 0; i < srcConvos.length; i++) { expect(dstConvos[i].conversationId).toBe(srcConvos[i].conversationId); expect(dstConvos[i].title).toBe(srcConvos[i].title); } console.log('[Restore] Data integrity verified: _ids, fields, and counts match exactly'); }, 30_000); it('verifies BSON type preservation (ObjectId, Date, Number)', async () => { const dstConn = baseConn.useDb(`${DB_PREFIX}org_${targetOrg}`, { useCache: true }); const user = await dstConn.db!.collection('users').findOne({ email: 'alice@backup.test' }); expect(user).toBeDefined(); expect(user!._id).toBeInstanceOf(mongoose.Types.ObjectId); expect(user!.createdAt).toBeInstanceOf(Date); const agent = await dstConn.db!.collection('agents').findOne({}); expect(agent).toBeDefined(); expect(agent!._id).toBeInstanceOf(mongoose.Types.ObjectId); expect(typeof agent!.name).toBe('string'); console.log('[Restore] BSON types preserved: ObjectId, Date, String all correct'); }); it('measures backup and restore performance', async () => { const srcConn = baseConn.useDb(`${DB_PREFIX}org_${sourceOrg}`, { useCache: true }); const backupStart = Date.now(); const backup = await backupOrg(srcConn, sourceOrg); const backupMs = Date.now() - backupStart; const freshDb = `${DB_PREFIX}org_perf_restore`; createdDbs.push(freshDb); const freshConn = baseConn.useDb(freshDb, { useCache: false }); const freshModels = registerModels(freshConn); await initializeOrgCollections(freshModels); const restoreStart = Date.now(); await restoreOrg(freshConn, backup); const restoreMs = Date.now() - restoreStart; const totalDocs = Object.values(backup.collections).reduce((s, d) => s + d.length, 0); console.log( `[Perf] Backup: ${backupMs}ms (${totalDocs} docs across ${Object.keys(backup.collections).length} collections)`, ); console.log(`[Perf] Restore: ${restoreMs}ms`); expect(backupMs).toBeLessThan(5000); expect(restoreMs).toBeLessThan(5000); }, 60_000); }); // ─── SCHEMA MIGRATION ────────────────────────────────────────────────── describe('schema migration across orgs', () => { const migrationOrgs = ['mig_1', 'mig_2', 'mig_3', 'mig_4', 'mig_5']; beforeAll(async () => { for (const orgId of migrationOrgs) { const dbName = `${DB_PREFIX}org_${orgId}`; createdDbs.push(dbName); const conn = baseConn.useDb(dbName, { useCache: true }); const models = registerModels(conn); await initializeOrgCollections(models); await models.User.create({ name: `User ${orgId}`, email: `user@${orgId}.test`, username: orgId, }); } }, 120_000); it('createIndexes is idempotent (no-op for existing indexes)', async () => { const conn = baseConn.useDb(`${DB_PREFIX}org_mig_1`, { useCache: true }); const models = registerModels(conn); const beforeIndexes = await models.User.collection.indexes(); const t0 = Date.now(); await initializeOrgCollections(models); const ms = Date.now() - t0; const afterIndexes = await models.User.collection.indexes(); expect(afterIndexes.length).toBe(beforeIndexes.length); console.log( `[Migration] Idempotent re-init: ${ms}ms (indexes unchanged: ${beforeIndexes.length})`, ); }, 60_000); it('adds a new collection to all existing orgs', async () => { const newSchema = new Schema( { orgId: { type: String, index: true }, eventType: { type: String, required: true, index: true }, payload: Schema.Types.Mixed, userId: { type: Schema.Types.ObjectId, index: true }, }, { timestamps: true }, ); newSchema.index({ orgId: 1, eventType: 1, createdAt: -1 }); for (const orgId of migrationOrgs) { const conn = baseConn.useDb(`${DB_PREFIX}org_${orgId}`, { useCache: true }); const AuditLog = conn.models['AuditLog'] || conn.model('AuditLog', newSchema); await AuditLog.createCollection(); await createIndexesWithRetry(AuditLog); } for (const orgId of migrationOrgs) { const conn = baseConn.useDb(`${DB_PREFIX}org_${orgId}`, { useCache: true }); const collections = (await conn.db!.listCollections().toArray()).map((c) => c.name); expect(collections).toContain('auditlogs'); const indexes = await conn.db!.collection('auditlogs').indexes(); expect(indexes.length).toBeGreaterThanOrEqual(4); } console.log( `[Migration] New collection 'auditlogs' added to ${migrationOrgs.length} orgs with 4+ indexes`, ); }, 60_000); it('adds a new index to an existing collection across all orgs', async () => { const indexSpec = { username: 1, createdAt: -1 }; for (const orgId of migrationOrgs) { const conn = baseConn.useDb(`${DB_PREFIX}org_${orgId}`, { useCache: true }); await retryWithBackoff( () => conn.db!.collection('users').createIndex(indexSpec, { background: true }), `createIndex(users, username+createdAt) for ${orgId}`, ); } for (const orgId of migrationOrgs) { const conn = baseConn.useDb(`${DB_PREFIX}org_${orgId}`, { useCache: true }); const indexes = await conn.db!.collection('users').indexes(); const hasNewIdx = indexes.some( (idx: Record) => JSON.stringify(idx.key) === JSON.stringify(indexSpec), ); expect(hasNewIdx).toBe(true); } console.log( `[Migration] New compound index added to 'users' across ${migrationOrgs.length} orgs`, ); }, 60_000); it('runs migrateAllOrgs and reports progress', async () => { const progress: string[] = []; const results = await migrateAllOrgs( baseConn, migrationOrgs, MODEL_SCHEMAS, (completed, total, result) => { progress.push( `${completed}/${total}: ${result.orgId} — ${result.totalMs}ms, ${result.newCollections.length} new collections`, ); }, ); console.log(`[Migration] Full migration across ${migrationOrgs.length} orgs:`); for (const p of progress) { console.log(` ${p}`); } const totalMs = results.reduce((s, r) => s + r.totalMs, 0); const avgMs = Math.round(totalMs / results.length); console.log(` Total: ${totalMs}ms, avg: ${avgMs}ms/org`); expect(results).toHaveLength(migrationOrgs.length); expect(results.every((r) => r.indexResults.length >= MODEL_COUNT)).toBe(true); }, 120_000); it('verifies existing data is preserved after migration', async () => { for (const orgId of migrationOrgs) { const conn = baseConn.useDb(`${DB_PREFIX}org_${orgId}`, { useCache: true }); const user = await conn.db!.collection('users').findOne({ email: `user@${orgId}.test` }); expect(user).toBeDefined(); expect(user!.name).toBe(`User ${orgId}`); } console.log( `[Migration] All existing user data preserved across ${migrationOrgs.length} orgs`, ); }); }); });