import { GridFSBucket, MongoClient, ReadPreference } from "mongodb"; import type { Conversation } from "$lib/types/Conversation"; import type { SharedConversation } from "$lib/types/SharedConversation"; import type { AbortedGeneration } from "$lib/types/AbortedGeneration"; import type { Generation, GenerationEvent } from "$lib/types/Generation"; import type { Settings } from "$lib/types/Settings"; import type { User } from "$lib/types/User"; import type { MessageEvent } from "$lib/types/MessageEvent"; import type { Session } from "$lib/types/Session"; import type { Assistant } from "$lib/types/Assistant"; import type { Report } from "$lib/types/Report"; import type { ConversationStats } from "$lib/types/ConversationStats"; import type { MigrationResult } from "$lib/types/MigrationResult"; import type { Semaphore } from "$lib/types/Semaphore"; import { MongoMemoryServer } from "mongodb-memory-server"; import { logger } from "$lib/server/logger"; import { building } from "$app/environment"; import type { TokenCache } from "$lib/types/TokenCache"; import { onExit } from "./exitHandler"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { existsSync, mkdirSync } from "fs"; import { findRepoRoot } from "./findRepoRoot"; import type { ConfigKey } from "$lib/types/ConfigKey"; import { config } from "$lib/server/config"; import type { AgentConfigurationLedger, AgentConfigurationReceipt, Workspace, } from "$lib/types/Workspace"; export const CONVERSATION_STATS_COLLECTION = "conversations.stats"; export class Database { private client?: MongoClient; private mongoServer?: MongoMemoryServer; private static instance: Database; private async init() { const DB_FOLDER = config.MONGO_STORAGE_PATH || join(findRepoRoot(dirname(fileURLToPath(import.meta.url))), "db"); if (!config.MONGODB_URL) { logger.warn("No MongoDB URL found, using in-memory server"); logger.info(`Using database path: ${DB_FOLDER}`); // Create db directory if it doesn't exist if (!existsSync(DB_FOLDER)) { logger.info(`Creating database directory at ${DB_FOLDER}`); mkdirSync(DB_FOLDER, { recursive: true }); } this.mongoServer = await MongoMemoryServer.create({ instance: { dbName: config.MONGODB_DB_NAME + (import.meta.env.MODE === "test" ? "-test" : ""), dbPath: DB_FOLDER, }, binary: { version: "7.0.18", }, }); this.client = new MongoClient(this.mongoServer.getUri(), { directConnection: config.MONGODB_DIRECT_CONNECTION === "true", }); } else { this.client = new MongoClient(config.MONGODB_URL, { directConnection: config.MONGODB_DIRECT_CONNECTION === "true", }); } try { logger.info("Connecting to database"); await this.client.connect(); logger.info("Connected to database"); this.client.db(config.MONGODB_DB_NAME + (import.meta.env.MODE === "test" ? "-test" : "")); await this.initDatabase(); } catch (err) { logger.error(err, "Error connecting to database"); process.exit(1); } // Disconnect DB on exit. Registered `last` so other exit handlers (e.g. the // reaper finalizing in-flight generations) finish their writes before the // client is force-closed. onExit( async () => { logger.info("Closing database connection"); await this.client?.close(true); await this.mongoServer?.stop(); }, { last: true } ); } public static async getInstance(): Promise { if (!Database.instance) { Database.instance = new Database(); await Database.instance.init(); } return Database.instance; } /** * Return mongoClient */ public getClient(): MongoClient { if (!this.client) { throw new Error("Database not initialized"); } return this.client; } /** * Return map of database's collections */ public getCollections() { if (!this.client) { throw new Error("Database not initialized"); } const db = this.client.db( config.MONGODB_DB_NAME + (import.meta.env.MODE === "test" ? "-test" : "") ); // Collections with default readPreference (primary) - critical for read-after-write consistency const conversations = db.collection("conversations"); const workspaces = db.collection("workspaces"); const agentConfigurationLedgers = db.collection( "agentConfigurationLedgers" ); const agentConfigurationReceipts = db.collection( "agentConfigurationReceipts" ); const settings = db.collection("settings"); const users = db.collection("users"); const sessions = db.collection("sessions"); const messageEvents = db.collection("messageEvents"); const abortedGenerations = db.collection("abortedGenerations"); const generations = db.collection("generations"); const generationEvents = db.collection("generationEvents"); const semaphores = db.collection("semaphores"); const tokenCaches = db.collection("tokens"); const configCollection = db.collection("config"); const migrationResults = db.collection("migrationResults"); const sharedConversations = db.collection("sharedConversations"); const bucket = new GridFSBucket(db, { bucketName: "files" }); // Collections with secondaryPreferred - heavy reads, can tolerate slight replication lag const secondaryPreferred = ReadPreference.SECONDARY_PREFERRED; const assistants = db.collection("assistants", { readPreference: secondaryPreferred, }); const conversationStats = db.collection(CONVERSATION_STATS_COLLECTION, { readPreference: secondaryPreferred, }); const reports = db.collection("reports", { readPreference: secondaryPreferred, }); const tools = db.collection("tools", { readPreference: secondaryPreferred, }); return { conversations, workspaces, agentConfigurationLedgers, agentConfigurationReceipts, conversationStats, assistants, reports, sharedConversations, abortedGenerations, generations, generationEvents, settings, users, sessions, messageEvents, bucket, migrationResults, semaphores, tokenCaches, tools, config: configCollection, }; } /** * Init database once connected: Index creation * @private */ private async initDatabase() { const { conversations, workspaces, agentConfigurationLedgers, agentConfigurationReceipts, conversationStats, assistants, reports, sharedConversations, abortedGenerations, generations, generationEvents, settings, users, sessions, messageEvents, semaphores, tokenCaches, config, } = this.getCollections(); // A conversation owns at most one durable agent workspace. Owner/status // indexes keep authorization and lifecycle sweeps bounded as the table grows. workspaces .createIndex({ conversationId: 1 }, { unique: true }) .catch((e) => logger.error(e, "Error creating unique workspace conversation index")); workspaces .createIndex( { userId: 1, "sandbox.status": 1, updatedAt: -1 }, { partialFilterExpression: { userId: { $exists: true } } } ) .catch((e) => logger.error(e, "Error creating workspace owner/status index")); workspaces .createIndex( { sessionId: 1, "sandbox.status": 1, updatedAt: -1 }, { partialFilterExpression: { sessionId: { $exists: true } } } ) .catch((e) => logger.error(e, "Error creating workspace session/status index")); workspaces .createIndex( { "sandbox.id": 1 }, { unique: true, partialFilterExpression: { "sandbox.id": { $type: "string" } }, } ) .catch((e) => logger.error(e, "Error creating unique workspace sandbox job index")); workspaces .createIndex({ "sandbox.status": 1, "sandbox.lastSeenAt": 1 }) .catch((e) => logger.error(e, "Error creating workspace lifecycle index")); workspaces .createIndex( { "llmProxy.tokenHash": 1 }, { unique: true, partialFilterExpression: { "llmProxy.tokenHash": { $type: "string" } }, } ) .catch((e) => logger.error(e, "Error creating unique workspace LLM proxy index")); // A browser capability must resolve to at most one exact workspace. Startup // fails if this security boundary cannot be installed. await workspaces.createIndex( { "preview.capabilityHash": 1 }, { name: "unique_workspace_preview_capability", unique: true, partialFilterExpression: { "preview.capabilityHash": { $type: "string" } }, } ); // The document id is the Workspace id. This secondary key makes orphan // cleanup by conversation bounded without weakening the exactly-once log. agentConfigurationLedgers .createIndex({ conversationId: 1 }) .catch((e) => logger.error(e, "Error creating Agent configuration ledger index")); await agentConfigurationReceipts.createIndex( { workspaceId: 1, controlId: 1 }, { name: "unique_agent_configuration_control", unique: true } ); await agentConfigurationReceipts.createIndex( { workspaceId: 1, sequence: 1 }, { name: "unique_agent_configuration_sequence", unique: true, partialFilterExpression: { sequence: { $type: "number" } }, } ); agentConfigurationReceipts .createIndex({ conversationId: 1 }) .catch((e) => logger.error(e, "Error creating Agent configuration receipt index")); conversations .createIndex( { sessionId: 1, updatedAt: -1 }, { partialFilterExpression: { sessionId: { $exists: true } } } ) .catch((e) => logger.error(e, "Error creating index for conversations by sessionId and updatedAt") ); conversations .createIndex( { userId: 1, updatedAt: -1 }, { partialFilterExpression: { userId: { $exists: true } } } ) .catch((e) => logger.error(e, "Error creating index for conversations by userId and updatedAt") ); conversations .createIndex( { "message.id": 1, "message.ancestors": 1 }, { partialFilterExpression: { userId: { $exists: true } } } ) .catch((e) => logger.error(e, "Error creating index for conversations by messageId and ancestors") ); // Not strictly necessary, could use _id, but more convenient. Also for stats // To do stats on conversation messages conversations .createIndex({ "messages.createdAt": 1 }, { sparse: true }) .catch((e) => logger.error(e, "Error creating index for conversations by messages createdAt") ); // Unique index for stats conversationStats .createIndex( { type: 1, "date.field": 1, "date.span": 1, "date.at": 1, distinct: 1, }, { unique: true } ) .catch((e) => logger.error( e, "Error creating index for conversationStats by type, date.field and date.span" ) ); // Allow easy check of last computed stat for given type/dateField conversationStats .createIndex({ type: 1, "date.field": 1, "date.at": 1, }) .catch((e) => logger.error(e, "Error creating index for abortedGenerations by updatedAt")); abortedGenerations .createIndex({ updatedAt: 1 }, { expireAfterSeconds: 30 }) .catch((e) => logger.error( e, "Error creating index for abortedGenerations by updatedAt and expireAfterSeconds" ) ); // Stop markers are generation-scoped. Replace the former conversation-only // unique index so a late marker from generation A cannot block or overwrite // generation B's marker in the same conversation. try { await abortedGenerations.dropIndex("conversationId_1"); } catch (cause) { if (![26, 27].includes((cause as { code?: number }).code ?? -1)) throw cause; } await abortedGenerations.createIndex( { conversationId: 1, generationId: 1 }, { name: "conversation_generation_unique", unique: true, partialFilterExpression: { generationId: { $type: "string" } }, } ); // This is the durable workspace execution claim. The bridge event stream is // workspace-global, so two running Agent generations could otherwise mirror // and terminate each other's turns. Unlike best-effort indexes below, startup // must fail if this invariant cannot be installed. await generations.createIndex( { workspaceId: 1 }, { name: "one_running_agent_generation_per_workspace", unique: true, partialFilterExpression: { mode: "agent", status: "running", workspaceId: { $type: "objectId" }, }, } ); // Client generation ids are idempotency keys, not advisory metadata. The // route reserves one before appending a message, so startup must fail closed // if MongoDB cannot enforce the corresponding global uniqueness invariant. await generations.createIndex({ generationId: 1 }, { unique: true }); generations .createIndex({ conversationId: 1, startedAt: -1 }) .catch((e) => logger.error(e, "Error creating index for generations by conversationId and startedAt") ); generations .createIndex( { userId: 1, updatedAt: -1 }, { partialFilterExpression: { userId: { $exists: true } } } ) .catch((e) => logger.error(e, "Error creating index for generations by userId")); generations .createIndex( { sessionId: 1, updatedAt: -1 }, { partialFilterExpression: { sessionId: { $exists: true } } } ) .catch((e) => logger.error(e, "Error creating index for generations by sessionId")); // Serves the reaper's query: still-running runs ordered by heartbeat age. generations .createIndex({ status: 1, lastHeartbeatAt: 1 }) .catch((e) => logger.error(e, "Error creating index for generations by status and lastHeartbeatAt") ); // Terminal recovery markers deliberately survive the status CAS until // message/workspace side effects finish, so the reaper must also find them // efficiently on already-terminal rows. generations .createIndex( { "agent.terminalization.expiresAt": 1 }, { name: "agent_terminalization_expiry", partialFilterExpression: { "agent.terminalization.expiresAt": { $type: "date" }, }, } ) .catch((e) => logger.error(e, "Error creating index for Agent terminal recovery")); generations .createIndex( { endedAt: 1 }, { expireAfterSeconds: 7 * 24 * 60 * 60, partialFilterExpression: { endedAt: { $exists: true } }, } ) .catch((e) => logger.error(e, "Error creating TTL index for generations by endedAt")); // Unique so a retried insert is idempotent, and compound so it is also the // exact index the replay/tail range scan uses. generationEvents .createIndex({ generationId: 1, seq: 1 }, { unique: true }) .catch((e) => logger.error(e, "Error creating index for generationEvents by generationId and seq") ); generationEvents .createIndex( { generationId: 1, sourceSequence: 1, sourceIndex: 1 }, { unique: true, partialFilterExpression: { sourceSequence: { $exists: true } }, } ) .catch((e) => logger.error(e, "Error creating Agent bridge event idempotency index")); generationEvents .createIndex( { generationId: 1, systemKey: 1, systemIndex: 1 }, { unique: true, partialFilterExpression: { systemKey: { $exists: true } }, } ) .catch((e) => logger.error(e, "Error creating Agent system event idempotency index")); generationEvents .createIndex({ createdAt: 1 }, { expireAfterSeconds: 24 * 60 * 60 }) .catch((e) => logger.error(e, "Error creating TTL index for generationEvents by createdAt")); sharedConversations.createIndex({ hash: 1 }, { unique: true }).catch((e) => logger.error(e)); settings .createIndex({ sessionId: 1 }, { unique: true, sparse: true }) .catch((e) => logger.error(e, "Error creating index for settings by sessionId")); settings .createIndex({ userId: 1 }, { unique: true, sparse: true }) .catch((e) => logger.error(e, "Error creating index for settings by userId")); settings .createIndex({ assistants: 1 }) .catch((e) => logger.error(e, "Error creating index for settings by assistants")); users .createIndex({ hfUserId: 1 }, { unique: true }) .catch((e) => logger.error(e, "Error creating index for users by hfUserId")); users .createIndex({ sessionId: 1 }, { unique: true, sparse: true }) .catch((e) => logger.error(e, "Error creating index for users by sessionId")); // No unicity because due to renames & outdated info from oauth provider, there may be the same username on different users users .createIndex({ username: 1 }) .catch((e) => logger.error(e, "Error creating index for users by username")); // For stats queries filtering users by creation date users .createIndex({ createdAt: 1 }) .catch((e) => logger.error(e, "Error creating index for users by createdAt")); messageEvents .createIndex({ expiresAt: 1 }, { expireAfterSeconds: 1 }) .catch((e) => logger.error(e, "Error creating index for messageEvents by expiresAt")); sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch((e) => logger.error(e)); sessions .createIndex({ sessionId: 1 }, { unique: true }) .catch((e) => logger.error(e, "Error creating index for sessions by sessionId")); assistants .createIndex({ createdById: 1, userCount: -1 }) .catch((e) => logger.error(e, "Error creating index for assistants by createdById and userCount") ); assistants .createIndex({ userCount: 1 }) .catch((e) => logger.error(e, "Error creating index for assistants by userCount")); assistants .createIndex({ review: 1, userCount: -1 }) .catch((e) => logger.error(e, "Error creating index for assistants by review and userCount")); assistants .createIndex({ modelId: 1, userCount: -1 }) .catch((e) => logger.error(e, "Error creating index for assistants by modelId and userCount") ); assistants .createIndex({ searchTokens: 1 }) .catch((e) => logger.error(e, "Error creating index for assistants by searchTokens")); assistants .createIndex({ last24HoursCount: 1 }) .catch((e) => logger.error(e, "Error creating index for assistants by last24HoursCount")); assistants .createIndex({ last24HoursUseCount: -1, useCount: -1, _id: 1 }) .catch((e) => logger.error(e, "Error creating index for assistants by last24HoursUseCount and useCount") ); reports .createIndex({ assistantId: 1 }) .catch((e) => logger.error(e, "Error creating index for reports by assistantId")); reports .createIndex({ createdBy: 1, assistantId: 1 }) .catch((e) => logger.error(e, "Error creating index for reports by createdBy and assistantId") ); // Unique index for semaphore and migration results semaphores.createIndex({ key: 1 }, { unique: true }).catch((e) => logger.error(e)); semaphores .createIndex({ deleteAt: 1 }, { expireAfterSeconds: 1 }) .catch((e) => logger.error(e, "Error creating index for semaphores by deleteAt")); tokenCaches .createIndex({ createdAt: 1 }, { expireAfterSeconds: 5 * 60 }) .catch((e) => logger.error(e, "Error creating index for tokenCaches by createdAt")); tokenCaches .createIndex({ tokenHash: 1 }) .catch((e) => logger.error(e, "Error creating index for tokenCaches by tokenHash")); conversations .createIndex({ "messages.from": 1, createdAt: 1, }) .catch((e) => logger.error(e, "Error creating index for conversations by messages from and createdAt") ); conversations .createIndex({ userId: 1, sessionId: 1, }) .catch((e) => logger.error(e, "Error creating index for conversations by userId and sessionId") ); // For stats aggregation jobs that filter by createdAt/updatedAt alone conversations .createIndex({ createdAt: 1 }) .catch((e) => logger.error(e, "Error creating index for conversations by createdAt")); conversations .createIndex({ updatedAt: 1 }) .catch((e) => logger.error(e, "Error creating index for conversations by updatedAt")); config .createIndex({ key: 1 }, { unique: true }) .catch((e) => logger.error(e, "Error creating index for config by key")); } } export let collections: ReturnType; export const ready = (async () => { if (!building) { const db = await Database.getInstance(); collections = db.getCollections(); } else { collections = {} as unknown as ReturnType; } })(); export async function getCollectionsEarly(): Promise< ReturnType > { await ready; if (!collections) { throw new Error("Database not initialized"); } return collections; }