diff --git a/.changeset/mongodb-feature-storage.md b/.changeset/mongodb-feature-storage.md new file mode 100644 index 000000000..59baf61ea --- /dev/null +++ b/.changeset/mongodb-feature-storage.md @@ -0,0 +1,5 @@ +--- +"@voltagent/mongodb": minor +--- + +Initial release of MongoDB memory storage adapter diff --git a/packages/mongodb/README.md b/packages/mongodb/README.md new file mode 100644 index 000000000..f3e5ce150 --- /dev/null +++ b/packages/mongodb/README.md @@ -0,0 +1,40 @@ +# @voltagent/mongodb + +MongoDB storage adapter for VoltAgent memory. + +## Installation + +```bash +npm install @voltagent/mongodb +``` + +## Usage + +```typescript +import { MongoDBMemoryAdapter } from "@voltagent/mongodb"; +import { Memory } from "@voltagent/core"; + +const memory = new Memory({ + storage: new MongoDBMemoryAdapter({ + connection: process.env.MONGO_URI, + database: "voltagent", // optional + collectionPrefix: "voltagent_memory", // optional + }), +}); +``` + +## Features + +- **Persistent Storage**: Stores messages, conversations, and workflow states in MongoDB. +- **Efficient Queries**: Indexed for fast retrieval by user, conversation, or date. +- **Type Safe**: Fully typed implementation of the VoltAgent StorageAdapter interface. +- **Workflow Support**: Native support for resuming suspended workflows. + +## Configuration + +| Option | Type | Default | Description | +| ------------------ | --------- | -------------------- | ---------------------- | +| `connection` | `string` | required | MongoDB connection URI | +| `database` | `string` | `"voltagent"` | Database name | +| `collectionPrefix` | `string` | `"voltagent_memory"` | Prefix for collections | +| `debug` | `boolean` | `false` | Enable debug logging | diff --git a/packages/mongodb/docker-compose.test.yaml b/packages/mongodb/docker-compose.test.yaml new file mode 100644 index 000000000..0f5400cae --- /dev/null +++ b/packages/mongodb/docker-compose.test.yaml @@ -0,0 +1,18 @@ +services: + mongodb-test: + image: mongo:7.0 + container_name: 'voltagent-mongodb-test' + ports: + - '27017:27017' + environment: + MONGO_INITDB_DATABASE: voltagent_test + volumes: + - test_mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 2s + timeout: 5s + retries: 10 + +volumes: + test_mongodb_data: diff --git a/packages/mongodb/package.json b/packages/mongodb/package.json new file mode 100644 index 000000000..1524a7f57 --- /dev/null +++ b/packages/mongodb/package.json @@ -0,0 +1,55 @@ +{ + "name": "@voltagent/mongodb", + "description": "VoltAgent MongoDB - MongoDB Memory provider integration for VoltAgent", + "version": "2.0.2", + "dependencies": { + "mongodb": "^7.0.0" + }, + "devDependencies": { + "@vitest/coverage-v8": "^3.2.4", + "@voltagent/core": "^2.0.2", + "ai": "^6.0.0" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "files": [ + "dist" + ], + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "peerDependencies": { + "@voltagent/core": "^2.0.0", + "ai": "^6.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/VoltAgent/voltagent.git", + "directory": "packages/mongodb" + }, + "scripts": { + "attw": "attw --pack", + "build": "tsup", + "dev": "tsup --watch", + "lint": "biome check .", + "lint:fix": "biome check . --write", + "publint": "publint --strict", + "test": "vitest", + "test:coverage": "vitest run --coverage", + "test:integration": "npm run test:integration:setup && vitest run --config vitest.integration.config.mts && npm run test:integration:teardown", + "test:integration:ci": "vitest run --config vitest.integration.config.mts", + "test:integration:setup": "docker compose -f docker-compose.test.yaml up -d && sleep 10", + "test:integration:teardown": "docker compose -f docker-compose.test.yaml down -v" + }, + "types": "dist/index.d.ts" +} diff --git a/packages/mongodb/src/index.integration.test.ts b/packages/mongodb/src/index.integration.test.ts new file mode 100644 index 000000000..9991af3aa --- /dev/null +++ b/packages/mongodb/src/index.integration.test.ts @@ -0,0 +1,651 @@ +/** + * Integration tests for MongoDB Memory Storage Adapter + * Tests against real MongoDB instance running in Docker + */ + +import { ConversationAlreadyExistsError, ConversationNotFoundError } from "@voltagent/core"; +import type { UIMessage } from "ai"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { MongoDBMemoryAdapter } from "./memory-adapter"; + +describe("MongoDBMemoryAdapter - Integration Tests", () => { + let adapter: MongoDBMemoryAdapter; + + const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017"; + const TEST_DATABASE = "voltagent_test"; + + beforeAll(async () => { + // Create adapter with test database + adapter = new MongoDBMemoryAdapter({ + connection: MONGO_URI, + database: TEST_DATABASE, + collectionPrefix: "test_memory", + debug: false, + }); + + // Wait for initialization + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + beforeEach(async () => { + // Clean up all collections before each test + const { MongoClient } = await import("mongodb"); + const client = new MongoClient(MONGO_URI); + await client.connect(); + const db = client.db(TEST_DATABASE); + + const collections = await db.listCollections().toArray(); + for (const collection of collections) { + if (collection.name.startsWith("test_memory_")) { + await db.collection(collection.name).deleteMany({}); + } + } + + await client.close(); + }); + + afterAll(async () => { + // Close adapter connection + await adapter.close(); + }); + + // ============================================================================ + // Message Operations Integration Tests + // ============================================================================ + + describe("Message Operations", () => { + it("should add and retrieve messages", async () => { + // Create conversation first + const conversation = await adapter.createConversation({ + id: "conv-1", + resourceId: "resource-1", + userId: "user-1", + title: "Test Conversation", + metadata: {}, + }); + + expect(conversation.id).toBe("conv-1"); + + // Add message + const message: UIMessage = { + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "Hello, world!" }], + metadata: { custom: "data" }, + }; + + await adapter.addMessage(message, "user-1", "conv-1"); + + // Retrieve messages + const messages = await adapter.getMessages("user-1", "conv-1"); + + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe("msg-1"); + expect(messages[0].role).toBe("user"); + expect(messages[0].parts).toEqual(message.parts); + expect(messages[0].metadata?.createdAt).toBeInstanceOf(Date); + }); + + it("should add multiple messages in batch", async () => { + await adapter.createConversation({ + id: "conv-2", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + const messages: UIMessage[] = [ + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + { id: "msg-2", role: "assistant", parts: [{ type: "text", text: "Hi there!" }] }, + { id: "msg-3", role: "user", parts: [{ type: "text", text: "How are you?" }] }, + ]; + + await adapter.addMessages(messages, "user-1", "conv-2"); + + const retrieved = await adapter.getMessages("user-1", "conv-2"); + + expect(retrieved).toHaveLength(3); + expect(retrieved.map((m) => m.id)).toEqual(["msg-1", "msg-2", "msg-3"]); + }); + + it("should filter messages by role", async () => { + await adapter.createConversation({ + id: "conv-3", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + await adapter.addMessages( + [ + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + { id: "msg-2", role: "assistant", parts: [{ type: "text", text: "Hi" }] }, + { id: "msg-3", role: "user", parts: [{ type: "text", text: "How are you?" }] }, + ], + "user-1", + "conv-3", + ); + + const userMessages = await adapter.getMessages("user-1", "conv-3", { roles: ["user"] }); + + expect(userMessages).toHaveLength(2); + expect(userMessages.every((m) => m.role === "user")).toBe(true); + }); + + it("should clear messages for a conversation", async () => { + await adapter.createConversation({ + id: "conv-4", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + await adapter.addMessage( + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + "user-1", + "conv-4", + ); + + await adapter.clearMessages("user-1", "conv-4"); + + const messages = await adapter.getMessages("user-1", "conv-4"); + expect(messages).toHaveLength(0); + }); + + it("should throw error when adding message to non-existent conversation", async () => { + const message: UIMessage = { + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + }; + + await expect(adapter.addMessage(message, "user-1", "non-existent")).rejects.toThrow( + ConversationNotFoundError, + ); + }); + }); + + // ============================================================================ + // Conversation Operations Integration Tests + // ============================================================================ + + describe("Conversation Operations", () => { + it("should create and retrieve conversation", async () => { + const input = { + id: "conv-create-test", + resourceId: "resource-1", + userId: "user-1", + title: "Test Conversation", + metadata: { custom: "field" }, + }; + + const created = await adapter.createConversation(input); + + expect(created.id).toBe(input.id); + expect(created.title).toBe(input.title); + expect(created.metadata).toEqual(input.metadata); + expect(created.createdAt).toBeTypeOf("string"); + expect(created.updatedAt).toBeTypeOf("string"); + + const retrieved = await adapter.getConversation("conv-create-test"); + + expect(retrieved).not.toBeNull(); + expect(retrieved?.id).toBe(input.id); + }); + + it("should throw error when creating duplicate conversation", async () => { + const input = { + id: "conv-duplicate", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }; + + await adapter.createConversation(input); + + await expect(adapter.createConversation(input)).rejects.toThrow( + ConversationAlreadyExistsError, + ); + }); + + it("should update conversation", async () => { + await adapter.createConversation({ + id: "conv-update", + resourceId: "resource-1", + userId: "user-1", + title: "Original Title", + metadata: {}, + }); + + const updated = await adapter.updateConversation("conv-update", { + title: "Updated Title", + metadata: { updated: true }, + }); + + expect(updated.title).toBe("Updated Title"); + expect(updated.metadata.updated).toBe(true); + }); + + it("should delete conversation and cascade to messages", async () => { + await adapter.createConversation({ + id: "conv-delete", + resourceId: "resource-1", + userId: "user-1", + title: "To Delete", + metadata: {}, + }); + + await adapter.addMessage( + { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, + "user-1", + "conv-delete", + ); + + await adapter.deleteConversation("conv-delete"); + + const conversation = await adapter.getConversation("conv-delete"); + expect(conversation).toBeNull(); + + const messages = await adapter.getMessages("user-1", "conv-delete"); + expect(messages).toHaveLength(0); + }); + + it("should query conversations with pagination", async () => { + // Create multiple conversations + for (let i = 1; i <= 5; i++) { + await adapter.createConversation({ + id: `conv-query-${i}`, + resourceId: "resource-1", + userId: "user-1", + title: `Conversation ${i}`, + metadata: {}, + }); + // Small delay to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + const page1 = await adapter.queryConversations({ + userId: "user-1", + limit: 2, + offset: 0, + }); + + expect(page1).toHaveLength(2); + + const page2 = await adapter.queryConversations({ + userId: "user-1", + limit: 2, + offset: 2, + }); + + expect(page2).toHaveLength(2); + expect(page1[0].id).not.toBe(page2[0].id); + }); + + it("should get conversations by resourceId", async () => { + await adapter.createConversation({ + id: "conv-res-1", + resourceId: "resource-test", + userId: "user-1", + title: "Test 1", + metadata: {}, + }); + + await adapter.createConversation({ + id: "conv-res-2", + resourceId: "resource-test", + userId: "user-2", + title: "Test 2", + metadata: {}, + }); + + const conversations = await adapter.getConversations("resource-test"); + + expect(conversations).toHaveLength(2); + }); + }); + + // ============================================================================ + // Working Memory Integration Tests + // ============================================================================ + + describe("Working Memory Operations", () => { + it("should set and get conversation-scoped working memory", async () => { + await adapter.createConversation({ + id: "conv-memory", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + await adapter.setWorkingMemory({ + conversationId: "conv-memory", + content: "Important context about this conversation", + scope: "conversation", + }); + + const memory = await adapter.getWorkingMemory({ + conversationId: "conv-memory", + scope: "conversation", + }); + + expect(memory).toBe("Important context about this conversation"); + }); + + it("should set and get user-scoped working memory", async () => { + await adapter.setWorkingMemory({ + userId: "user-memory-test", + content: "User preferences and context", + scope: "user", + }); + + const memory = await adapter.getWorkingMemory({ + userId: "user-memory-test", + scope: "user", + }); + + expect(memory).toBe("User preferences and context"); + }); + + it("should delete working memory", async () => { + await adapter.createConversation({ + id: "conv-del-memory", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + await adapter.setWorkingMemory({ + conversationId: "conv-del-memory", + content: "Test memory", + scope: "conversation", + }); + + await adapter.deleteWorkingMemory({ + conversationId: "conv-del-memory", + scope: "conversation", + }); + + const memory = await adapter.getWorkingMemory({ + conversationId: "conv-del-memory", + scope: "conversation", + }); + + expect(memory).toBeNull(); + }); + }); + + // ============================================================================ + // Workflow State Integration Tests + // ============================================================================ + + describe("Workflow State Operations", () => { + it("should set and get workflow state", async () => { + const state: any = { + id: "exec-1", + workflowId: "workflow-1", + workflowName: "Test Workflow", + status: "running", + suspension: null, + events: [], + output: null, + cancellation: null, + userId: "user-1", + conversationId: "conv-1", + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await adapter.setWorkflowState("exec-1", state); + + const retrieved = await adapter.getWorkflowState("exec-1"); + + expect(retrieved).not.toBeNull(); + expect(retrieved?.id).toBe("exec-1"); + expect(retrieved?.status).toBe("running"); + }); + + it("should update workflow state", async () => { + const state: any = { + id: "exec-update", + workflowId: "workflow-1", + workflowName: "Test", + status: "running", + createdAt: new Date(), + updatedAt: new Date(), + }; + + await adapter.setWorkflowState("exec-update", state); + + await adapter.updateWorkflowState("exec-update", { + status: "completed", + output: { result: "success" }, + }); + + const updated = await adapter.getWorkflowState("exec-update"); + + expect(updated?.status).toBe("completed"); + expect(updated?.output).toEqual({ result: "success" }); + }); + + it("should query workflow runs", async () => { + const now = new Date(); + + for (let i = 1; i <= 3; i++) { + await adapter.setWorkflowState(`exec-query-${i}`, { + id: `exec-query-${i}`, + workflowId: "workflow-query", + workflowName: "Test", + status: i === 1 ? "completed" : "running", + createdAt: now, + updatedAt: now, + } as any); + } + + const allRuns = await adapter.queryWorkflowRuns({ + workflowId: "workflow-query", + }); + + expect(allRuns.length).toBeGreaterThanOrEqual(3); + + const completedRuns = await adapter.queryWorkflowRuns({ + workflowId: "workflow-query", + status: "completed", + }); + + expect(completedRuns).toHaveLength(1); + }); + + it("should get suspended workflow states", async () => { + const now = new Date(); + + await adapter.setWorkflowState("exec-suspended-1", { + id: "exec-suspended-1", + workflowId: "workflow-suspend", + workflowName: "Test", + status: "suspended", + createdAt: now, + updatedAt: now, + } as any); + + await adapter.setWorkflowState("exec-running-1", { + id: "exec-running-1", + workflowId: "workflow-suspend", + workflowName: "Test", + status: "running", + createdAt: now, + updatedAt: now, + } as any); + + const suspended = await adapter.getSuspendedWorkflowStates("workflow-suspend"); + + expect(suspended).toHaveLength(1); + expect(suspended[0].status).toBe("suspended"); + }); + }); + + // ============================================================================ + // Conversation Steps Integration Tests + // ============================================================================ + + describe("Conversation Steps Operations", () => { + it("should save and retrieve conversation steps", async () => { + await adapter.createConversation({ + id: "conv-steps", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + const steps: any[] = [ + { + id: "step-1", + conversationId: "conv-steps", + userId: "user-1", + agentId: "agent-1", + agentName: "Test Agent", + operationId: "op-1", + stepIndex: 0, + type: "message", + role: "user", + content: "Hello", + }, + { + id: "step-2", + conversationId: "conv-steps", + userId: "user-1", + agentId: "agent-1", + agentName: "Test Agent", + operationId: "op-1", + stepIndex: 1, + type: "message", + role: "assistant", + content: "Hi there", + }, + ]; + + await adapter.saveConversationSteps(steps); + + const retrieved = await adapter.getConversationSteps("user-1", "conv-steps"); + + expect(retrieved).toHaveLength(2); + expect(retrieved[0].stepIndex).toBe(0); + expect(retrieved[1].stepIndex).toBe(1); + }); + + it("should filter steps by operationId", async () => { + await adapter.createConversation({ + id: "conv-steps-filter", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + await adapter.saveConversationSteps([ + { + id: "step-op1", + conversationId: "conv-steps-filter", + userId: "user-1", + agentId: "agent-1", + operationId: "op-1", + stepIndex: 0, + type: "message", + role: "user", + } as any, + { + id: "step-op2", + conversationId: "conv-steps-filter", + userId: "user-1", + agentId: "agent-1", + operationId: "op-2", + stepIndex: 1, + type: "message", + role: "user", + } as any, + ]); + + const op1Steps = await adapter.getConversationSteps("user-1", "conv-steps-filter", { + operationId: "op-1", + }); + + expect(op1Steps).toHaveLength(1); + expect(op1Steps[0].operationId).toBe("op-1"); + }); + }); + + // ============================================================================ + // Edge Cases and Error Handling + // ============================================================================ + + describe("Edge Cases", () => { + it("should handle empty message arrays", async () => { + await adapter.createConversation({ + id: "conv-empty", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + await adapter.addMessages([], "user-1", "conv-empty"); + const messages = await adapter.getMessages("user-1", "conv-empty"); + + expect(messages).toHaveLength(0); + }); + + it("should return empty array for non-existent conversation messages", async () => { + const messages = await adapter.getMessages("user-1", "non-existent"); + expect(messages).toHaveLength(0); + }); + + it("should handle messages without IDs", async () => { + await adapter.createConversation({ + id: "conv-no-id", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + const message: UIMessage = { + role: "user", + parts: [{ type: "text", text: "Hello" }], + id: "", + }; + + await adapter.addMessage(message, "user-1", "conv-no-id"); + + const messages = await adapter.getMessages("user-1", "conv-no-id"); + + expect(messages).toHaveLength(1); + expect(messages[0].id).toBeTruthy(); + }); + + it("should handle pagination beyond available results", async () => { + await adapter.createConversation({ + id: "conv-pagination", + resourceId: "resource-1", + userId: "user-1", + title: "Test", + metadata: {}, + }); + + const conversations = await adapter.queryConversations({ + userId: "user-1", + limit: 10, + offset: 100, + }); + + expect(conversations).toHaveLength(0); + }); + }); +}); diff --git a/packages/mongodb/src/index.ts b/packages/mongodb/src/index.ts new file mode 100644 index 000000000..cfebb019d --- /dev/null +++ b/packages/mongodb/src/index.ts @@ -0,0 +1,2 @@ +export { MongoDBMemoryAdapter } from "./memory-adapter"; +export type { MongoDBMemoryOptions } from "./memory-adapter"; diff --git a/packages/mongodb/src/memory-adapter.spec.ts b/packages/mongodb/src/memory-adapter.spec.ts new file mode 100644 index 000000000..82caa89ba --- /dev/null +++ b/packages/mongodb/src/memory-adapter.spec.ts @@ -0,0 +1,73 @@ +import { MongoClient } from "mongodb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MongoDBMemoryAdapter } from "./memory-adapter"; + +vi.mock("mongodb", () => { + const collection = { + createIndex: vi.fn(), + insertOne: vi.fn(), + insertMany: vi.fn(), + find: vi.fn().mockReturnThis(), + sort: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + skip: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + updateOne: vi.fn(), + deleteMany: vi.fn(), + deleteOne: vi.fn(), + countDocuments: vi.fn(), + bulkWrite: vi.fn(), + }; + + const db = { + collection: vi.fn().mockReturnValue(collection), + }; + + const client = { + connect: vi.fn(), + db: vi.fn().mockReturnValue(db), + close: vi.fn(), + }; + + return { + MongoClient: vi.fn().mockImplementation(() => client), + }; +}); + +describe("MongoDBMemoryAdapter", () => { + let adapter: MongoDBMemoryAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new MongoDBMemoryAdapter({ + connection: "mongodb://localhost:27017", + }); + }); + + afterEach(async () => { + await adapter.close(); + }); + + it("should be defined", () => { + expect(adapter).toBeDefined(); + }); + + it("should initialize correctly", async () => { + await (adapter as any).initialize(); + expect(MongoClient).toHaveBeenCalledTimes(1); + }); + + it("should perform createConversation", async () => { + const input = { + id: "test-conv-id", + resourceId: "resource-1", + userId: "user-1", + title: "Test Conversation", + metadata: {}, + }; + + const conv = await adapter.createConversation(input); + expect(conv.id).toBe(input.id); + }); +}); diff --git a/packages/mongodb/src/memory-adapter.ts b/packages/mongodb/src/memory-adapter.ts new file mode 100644 index 000000000..91e5b5d94 --- /dev/null +++ b/packages/mongodb/src/memory-adapter.ts @@ -0,0 +1,1024 @@ +/** + * MongoDB Storage Adapter for Memory + * Stores conversations and messages in MongoDB database + */ + +import { ConversationAlreadyExistsError, ConversationNotFoundError } from "@voltagent/core"; +import type { + Conversation, + ConversationQueryOptions, + ConversationStepRecord, + CreateConversationInput, + GetConversationStepsOptions, + GetMessagesOptions, + StorageAdapter, + WorkflowRunQuery, + WorkflowStateEntry, + WorkingMemoryScope, +} from "@voltagent/core"; +import type { UIMessage } from "ai"; +import { type Collection, type Db, type Document, MongoClient } from "mongodb"; + +/** + * MongoDB configuration options for Memory + */ +export interface MongoDBMemoryOptions { + /** + * MongoDB connection URI + * Examples: + * - "mongodb://localhost:27017" + * - "mongodb://username:password@localhost:27017" + * - "mongodb+srv://username:password@cluster.mongodb.net" + */ + connection: string; + + /** + * Database name to use for collections + * @default "voltagent" + */ + database?: string; + + /** + * Prefix for collection names + * @default "voltagent_memory" + */ + collectionPrefix?: string; + + /** + * Whether to enable debug logging + * @default false + */ + debug?: boolean; +} + +/** + * MongoDB Storage Adapter for Memory + * Production-ready storage for conversations and messages + */ +export class MongoDBMemoryAdapter implements StorageAdapter { + private client: MongoClient; + private db: Db | null = null; + private databaseName: string; + private collectionPrefix: string; + private initialized = false; + private initPromise: Promise | null = null; + private debug: boolean; + + constructor(options: MongoDBMemoryOptions) { + this.databaseName = options.database ?? "voltagent"; + this.collectionPrefix = options.collectionPrefix ?? "voltagent_memory"; + this.debug = options.debug ?? false; + + // Validate collection prefix + if ( + this.collectionPrefix.includes("\0") || + this.collectionPrefix.includes("$") || + this.collectionPrefix.startsWith("system.") + ) { + throw new Error(`Invalid collection prefix: ${this.collectionPrefix}`); + } + + // Create MongoDB client + this.client = new MongoClient(options.connection); + + this.log("MongoDB Memory adapter initialized"); + + // Start initialization but don't await it + this.initPromise = this.initialize(); + } + + /** + * Log debug messages + */ + private log(...args: any[]): void { + if (this.debug) { + console.log("[MongoDB Memory]", ...args); + } + } + + /** + * Generate a random ID + */ + private generateId(): string { + return ( + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + ); + } + + /** + * Get collection by name + */ + private getCollection(collectionName: string): Collection { + if (!this.db) { + throw new Error("Database not initialized"); + } + return this.db.collection(`${this.collectionPrefix}_${collectionName}`); + } + + /** + * Initialize database schema + */ + private async initialize(): Promise { + if (this.initialized) return; + + // Prevent multiple simultaneous initializations + if (this.initPromise && !this.initialized) { + return this.initPromise; + } + + try { + // Connect to MongoDB + await this.client.connect(); + this.db = this.client.db(this.databaseName); + + this.log(`Connected to MongoDB database: ${this.databaseName}`); + + // Create indexes for all collections + + const conversationsCollection = this.getCollection("conversations"); + const messagesCollection = this.getCollection("messages"); + const workflowStatesCollection = this.getCollection("workflow_states"); + const stepsCollection = this.getCollection("steps"); + + // Users collection indexes (none needed beyond _id) + + // Conversations collection indexes + await conversationsCollection.createIndex({ userId: 1 }, { background: true }); + await conversationsCollection.createIndex({ resourceId: 1 }, { background: true }); + await conversationsCollection.createIndex({ updatedAt: -1 }, { background: true }); + + // Messages collection indexes + await messagesCollection.createIndex( + { conversationId: 1, createdAt: 1 }, + { background: true }, + ); + await messagesCollection.createIndex({ conversationId: 1 }, { background: true }); + // Unique compound index to enforce message uniqueness + await messagesCollection.createIndex( + { conversationId: 1, messageId: 1 }, + { unique: true, background: true }, + ); + + // Workflow states collection indexes + await workflowStatesCollection.createIndex({ workflowId: 1 }, { background: true }); + await workflowStatesCollection.createIndex({ status: 1 }, { background: true }); + await workflowStatesCollection.createIndex({ createdAt: -1 }, { background: true }); + + // Steps collection indexes + await stepsCollection.createIndex({ conversationId: 1, stepIndex: 1 }, { background: true }); + await stepsCollection.createIndex( + { conversationId: 1, operationId: 1 }, + { background: true }, + ); + + this.initialized = true; + this.log("Database schema initialized with indexes"); + } catch (error) { + throw new Error( + `Failed to connect to MongoDB: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Close MongoDB connection + */ + async close(): Promise { + await this.client.close(); + this.log("MongoDB connection closed"); + } + + // ============================================================================ + // Message Operations + // ============================================================================ + + /** + * Add a single message + */ + async addMessage(message: UIMessage, userId: string, conversationId: string): Promise { + await this.initPromise; + + const messagesCollection = this.getCollection("messages"); + + // Ensure conversation exists + const conversation = await this.getConversation(conversationId); + if (!conversation) { + throw new ConversationNotFoundError(conversationId); + } + + const messageId = message.id || this.generateId(); + + try { + await messagesCollection.insertOne({ + conversationId, + messageId, + userId, + role: message.role, + parts: message.parts, + metadata: message.metadata || {}, + formatVersion: 2, + createdAt: new Date(), + } as any); + + this.log(`Added message ${messageId} to conversation ${conversationId}`); + } catch (error: any) { + if (error.code === 11000) { + throw new Error( + `Message with ID ${messageId} already exists in conversation ${conversationId}`, + ); + } + throw error; + } + } + + /** + * Add multiple messages + */ + async addMessages(messages: UIMessage[], userId: string, conversationId: string): Promise { + await this.initPromise; + + if (messages.length === 0) return; + + const messagesCollection = this.getCollection("messages"); + + // Ensure conversation exists + const conversation = await this.getConversation(conversationId); + if (!conversation) { + throw new ConversationNotFoundError(conversationId); + } + + const documentsToInsert = messages.map((message) => ({ + conversationId, + messageId: message.id || this.generateId(), + userId, + role: message.role, + parts: message.parts, + metadata: message.metadata || {}, + formatVersion: 2, + createdAt: new Date(), + })); + + try { + await messagesCollection.insertMany(documentsToInsert as any); + this.log(`Added ${messages.length} messages to conversation ${conversationId}`); + } catch (error: any) { + if (error.code === 11000) { + throw new Error(`One or more messages already exist in conversation ${conversationId}`); + } + throw error; + } + } + + /** + * Get messages for a conversation + */ + async getMessages( + userId: string, + conversationId: string, + options?: GetMessagesOptions, + ): Promise[]> { + await this.initPromise; + + const messagesCollection = this.getCollection("messages"); + + const filter: any = { conversationId, userId }; + + if (options?.roles && options.roles.length > 0) { + filter.role = { $in: options.roles }; + } + + if (options?.after) { + filter.createdAt = { $gt: options.after }; + } + + if (options?.before) { + filter.createdAt = { ...filter.createdAt, $lt: options.before }; + } + + let cursor = messagesCollection.find(filter).sort({ createdAt: 1 }); + + if (options?.limit) { + cursor = cursor.limit(options.limit); + } + + const messages = await cursor.toArray(); + + return messages.map((msg: any) => ({ + id: msg.messageId, + role: msg.role, + parts: msg.parts, + metadata: { + ...msg.metadata, + createdAt: msg.createdAt, + }, + })); + } + + /** + * Delete specific messages + */ + async deleteMessages( + messageIds: string[], + userId: string, + conversationId: string, + ): Promise { + await this.initPromise; + + const messagesCollection = this.getCollection("messages"); + + await messagesCollection.deleteMany({ + conversationId, + userId, + messageId: { $in: messageIds }, + }); + + this.log(`Deleted ${messageIds.length} messages from conversation ${conversationId}`); + } + + /** + * Clear messages for a conversation or all conversations for a user + */ + async clearMessages(userId: string, conversationId?: string): Promise { + await this.initPromise; + + const messagesCollection = this.getCollection("messages"); + const stepsCollection = this.getCollection("steps"); + + if (conversationId) { + // Clear messages for specific conversation + await messagesCollection.deleteMany({ conversationId, userId }); + await stepsCollection.deleteMany({ conversationId, userId }); + this.log(`Cleared messages for conversation ${conversationId}`); + } else { + // Clear all messages for user + // First get all conversation IDs for this user + const conversationsCollection = this.getCollection("conversations"); + const userConversations = await conversationsCollection + .find({ userId }) + .project({ _id: 1 }) + .toArray(); + + const conversationIds = userConversations.map((conv: any) => conv._id); + + if (conversationIds.length > 0) { + await messagesCollection.deleteMany({ conversationId: { $in: conversationIds } }); + await stepsCollection.deleteMany({ conversationId: { $in: conversationIds } }); + this.log(`Cleared all messages for user ${userId}`); + } + } + } + + // ============================================================================ + // Conversation Operations + // ============================================================================ + + /** + * Create a new conversation + */ + async createConversation(input: CreateConversationInput): Promise { + await this.initPromise; + + const conversationsCollection = this.getCollection("conversations"); + + const now = new Date(); + const conversation = { + _id: input.id, + resourceId: input.resourceId, + userId: input.userId, + title: input.title, + metadata: input.metadata || {}, + createdAt: now, + updatedAt: now, + }; + + try { + await conversationsCollection.insertOne(conversation); + } catch (error: any) { + if (error.code === 11000) { + throw new ConversationAlreadyExistsError(input.id); + } + throw error; + } + + this.log(`Created conversation ${input.id}`); + + return { + id: conversation._id, + resourceId: conversation.resourceId, + userId: conversation.userId, + title: conversation.title, + metadata: conversation.metadata, + createdAt: conversation.createdAt.toISOString(), + updatedAt: conversation.updatedAt.toISOString(), + }; + } + + /** + * Count conversations based on filters + */ + async countConversations(options: ConversationQueryOptions): Promise { + await this.initPromise; + + const conversationsCollection = this.getCollection("conversations"); + const filter: any = {}; + + if (options.userId) { + filter.userId = options.userId; + } + + if (options.resourceId) { + filter.resourceId = options.resourceId; + } + + return conversationsCollection.countDocuments(filter); + } + + /** + * Get a conversation by ID + */ + async getConversation(id: string): Promise { + await this.initPromise; + + const conversationsCollection = this.getCollection("conversations"); + const conversation = await conversationsCollection.findOne({ _id: id } as any); + + if (!conversation) { + return null; + } + + return { + id: (conversation as any)._id, + resourceId: (conversation as any).resourceId, + userId: (conversation as any).userId, + title: (conversation as any).title, + metadata: (conversation as any).metadata || {}, + createdAt: (conversation as any).createdAt.toISOString(), + updatedAt: (conversation as any).updatedAt.toISOString(), + }; + } + + /** + * Get all conversations for a resource + */ + async getConversations(resourceId: string): Promise { + await this.initPromise; + + const conversationsCollection = this.getCollection("conversations"); + const conversations = await conversationsCollection + .find({ resourceId } as any) + .sort({ updatedAt: -1 }) + .toArray(); + + return conversations.map((conv: any) => ({ + id: conv._id, + resourceId: conv.resourceId, + userId: conv.userId, + title: conv.title, + metadata: conv.metadata || {}, + createdAt: conv.createdAt.toISOString(), + updatedAt: conv.updatedAt.toISOString(), + })); + } + + /** + * Get all conversations for a user + */ + async getConversationsByUserId( + userId: string, + options?: Omit, + ): Promise { + return this.queryConversations({ ...options, userId }); + } + + /** + * Query conversations with filters + */ + async queryConversations(options: ConversationQueryOptions): Promise { + await this.initPromise; + + const conversationsCollection = this.getCollection("conversations"); + + const filter: any = {}; + + if (options.userId) { + filter.userId = options.userId; + } + + if (options.resourceId) { + filter.resourceId = options.resourceId; + } + + let cursor = conversationsCollection.find(filter).sort({ updatedAt: -1 }); + + if (options.limit) { + cursor = cursor.limit(options.limit); + } + + if (options.offset) { + cursor = cursor.skip(options.offset); + } + + const conversations = await cursor.toArray(); + + return conversations.map((conv: any) => ({ + id: conv._id, + resourceId: conv.resourceId, + userId: conv.userId, + title: conv.title, + metadata: conv.metadata || {}, + createdAt: conv.createdAt.toISOString(), + updatedAt: conv.updatedAt.toISOString(), + })); + } + + /** + * Update a conversation + */ + async updateConversation( + id: string, + updates: Partial>, + ): Promise { + await this.initPromise; + + const conversationsCollection = this.getCollection("conversations"); + + const updateDoc: any = { + updatedAt: new Date(), + }; + + if (updates.title !== undefined) { + updateDoc.title = updates.title; + } + + if (updates.metadata !== undefined) { + updateDoc.metadata = updates.metadata; + } + + if (updates.resourceId !== undefined) { + updateDoc.resourceId = updates.resourceId; + } + + if (updates.userId !== undefined) { + updateDoc.userId = updates.userId; + } + + const result = await conversationsCollection.findOneAndUpdate( + { _id: id } as any, + { $set: updateDoc }, + { returnDocument: "after" }, + ); + + if (!result) { + throw new ConversationNotFoundError(id); + } + + this.log(`Updated conversation ${id}`); + + return { + id: (result as any)._id, + resourceId: (result as any).resourceId, + userId: (result as any).userId, + title: (result as any).title, + metadata: (result as any).metadata || {}, + createdAt: (result as any).createdAt.toISOString(), + updatedAt: (result as any).updatedAt.toISOString(), + }; + } + + /** + * Delete a conversation + */ + async deleteConversation(id: string): Promise { + await this.initPromise; + + const conversationsCollection = this.getCollection("conversations"); + + // MongoDB will cascade delete messages and steps automatically via application logic + const messagesCollection = this.getCollection("messages"); + const stepsCollection = this.getCollection("steps"); + + await messagesCollection.deleteMany({ conversationId: id } as any); + await stepsCollection.deleteMany({ conversationId: id } as any); + await conversationsCollection.deleteOne({ _id: id } as any); + + this.log(`Deleted conversation ${id}`); + } + + // ============================================================================ + // Conversation Steps Operations + // ============================================================================ + + /** + * Save conversation steps + */ + async saveConversationSteps(steps: ConversationStepRecord[]): Promise { + await this.initPromise; + + if (steps.length === 0) return; + + const stepsCollection = this.getCollection("steps"); + + const operations = steps.map((step) => { + const id = step.id || this.generateId(); + return { + updateOne: { + filter: { _id: id }, + update: { + $set: { + conversationId: step.conversationId, + userId: step.userId, + agentId: step.agentId, + agentName: step.agentName, + operationId: step.operationId, + stepIndex: step.stepIndex, + type: step.type, + role: step.role, + content: step.content, + arguments: step.arguments, + result: step.result, + usage: step.usage, + subAgentId: step.subAgentId, + subAgentName: step.subAgentName, + }, + $setOnInsert: { + _id: id, + createdAt: new Date(), + }, + }, + upsert: true, + }, + }; + }); + + await stepsCollection.bulkWrite(operations as any); + + this.log(`Saved ${steps.length} conversation steps`); + } + + /** + * Get conversation steps + */ + async getConversationSteps( + userId: string, + conversationId: string, + options?: GetConversationStepsOptions, + ): Promise { + await this.initPromise; + + const stepsCollection = this.getCollection("steps"); + + const filter: any = { conversationId, userId }; + + if (options?.operationId) { + filter.operationId = options.operationId; + } + + let cursor = stepsCollection.find(filter).sort({ stepIndex: 1 }); + + if (options?.limit) { + cursor = cursor.limit(options.limit); + } + + const steps = await cursor.toArray(); + + return steps.map((step: any) => ({ + id: step._id, + conversationId: step.conversationId, + userId: step.userId, + agentId: step.agentId, + agentName: step.agentName, + operationId: step.operationId, + stepIndex: step.stepIndex, + type: step.type, + role: step.role, + content: step.content, + arguments: step.arguments, + result: step.result, + usage: step.usage, + subAgentId: step.subAgentId, + subAgentName: step.subAgentName, + createdAt: step.createdAt.toISOString(), + })); + } + + // ============================================================================ + // Working Memory Operations + // ============================================================================ + + /** + * Get working memory + */ + async getWorkingMemory(params: { + conversationId?: string; + userId?: string; + scope: WorkingMemoryScope; + }): Promise { + await this.initPromise; + + if (params.scope === "conversation" && params.conversationId) { + const conversationsCollection = this.getCollection("conversations"); + const conversation = await conversationsCollection.findOne({ + _id: params.conversationId, + } as any); + + if (!conversation) { + return null; + } + + const workingMemory = (conversation as any).metadata?.workingMemory; + return workingMemory || null; + } + + if (params.scope === "user" && params.userId) { + const usersCollection = this.getCollection("users"); + const user = await usersCollection.findOne({ _id: params.userId } as any); + + if (!user) { + return null; + } + + const workingMemory = (user as any).metadata?.workingMemory; + return workingMemory || null; + } + + return null; + } + + /** + * Set working memory + */ + async setWorkingMemory(params: { + conversationId?: string; + userId?: string; + content: string; + scope: WorkingMemoryScope; + }): Promise { + await this.initPromise; + + if (params.scope === "conversation" && params.conversationId) { + const conversationsCollection = this.getCollection("conversations"); + + const conversation = await conversationsCollection.findOne({ + _id: params.conversationId, + } as any); + if (!conversation) { + throw new ConversationNotFoundError(params.conversationId); + } + + await conversationsCollection.updateOne({ _id: params.conversationId } as any, { + $set: { + "metadata.workingMemory": params.content, + updatedAt: new Date(), + }, + }); + + this.log(`Set working memory for conversation ${params.conversationId}`); + } else if (params.scope === "user" && params.userId) { + const usersCollection = this.getCollection("users"); + + // Upsert user document with working memory + await usersCollection.updateOne( + { _id: params.userId } as any, + { + $set: { + "metadata.workingMemory": params.content, + updatedAt: new Date(), + }, + $setOnInsert: { + createdAt: new Date(), + }, + }, + { upsert: true }, + ); + + this.log(`Set working memory for user ${params.userId}`); + } + } + + /** + * Delete working memory + */ + async deleteWorkingMemory(params: { + conversationId?: string; + userId?: string; + scope: WorkingMemoryScope; + }): Promise { + await this.initPromise; + + if (params.scope === "conversation" && params.conversationId) { + const conversationsCollection = this.getCollection("conversations"); + + await conversationsCollection.updateOne({ _id: params.conversationId } as any, { + $unset: { "metadata.workingMemory": "" }, + $set: { updatedAt: new Date() }, + }); + + this.log(`Deleted working memory for conversation ${params.conversationId}`); + } else if (params.scope === "user" && params.userId) { + const usersCollection = this.getCollection("users"); + + await usersCollection.updateOne({ _id: params.userId } as any, { + $unset: { "metadata.workingMemory": "" }, + $set: { updatedAt: new Date() }, + }); + + this.log(`Deleted working memory for user ${params.userId}`); + } + } + + // ============================================================================ + // Workflow State Operations + // ============================================================================ + + /** + * Get workflow state by execution ID + */ + async getWorkflowState(executionId: string): Promise { + await this.initPromise; + + const workflowStatesCollection = this.getCollection("workflow_states"); + const state = await workflowStatesCollection.findOne({ _id: executionId } as any); + + if (!state) { + return null; + } + + return { + id: (state as any)._id, + workflowId: (state as any).workflowId, + workflowName: (state as any).workflowName, + status: (state as any).status, + suspension: (state as any).suspension, + events: (state as any).events, + output: (state as any).output, + cancellation: (state as any).cancellation, + userId: (state as any).userId, + conversationId: (state as any).conversationId, + metadata: (state as any).metadata, + createdAt: (state as any).createdAt, + updatedAt: (state as any).updatedAt, + }; + } + + /** + * Query workflow runs with filters + */ + async queryWorkflowRuns(query: WorkflowRunQuery): Promise { + await this.initPromise; + + const workflowStatesCollection = this.getCollection("workflow_states"); + + const filter: any = {}; + + if (query.workflowId) { + filter.workflowId = query.workflowId; + } + + if (query.status) { + filter.status = query.status; + } + + if (query.from) { + filter.createdAt = { $gte: query.from }; + } + + if (query.to) { + filter.createdAt = { ...filter.createdAt, $lte: query.to }; + } + + let cursor = workflowStatesCollection.find(filter).sort({ createdAt: -1 }); + + if (query.limit) { + cursor = cursor.limit(query.limit); + } + + if (query.offset) { + cursor = cursor.skip(query.offset); + } + + const states = await cursor.toArray(); + + return states.map((state: any) => ({ + id: state._id, + workflowId: state.workflowId, + workflowName: state.workflowName, + status: state.status, + suspension: state.suspension, + events: state.events, + output: state.output, + cancellation: state.cancellation, + userId: state.userId, + conversationId: state.conversationId, + metadata: state.metadata, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + })); + } + + /** + * Set workflow state (create or replace) + */ + async setWorkflowState(executionId: string, state: WorkflowStateEntry): Promise { + await this.initPromise; + + const workflowStatesCollection = this.getCollection("workflow_states"); + + const now = new Date(); + + await workflowStatesCollection.replaceOne( + { _id: executionId } as any, + { + _id: executionId, + workflowId: state.workflowId, + workflowName: state.workflowName, + status: state.status, + suspension: state.suspension, + events: state.events, + output: state.output, + cancellation: state.cancellation, + userId: state.userId, + conversationId: state.conversationId, + metadata: state.metadata, + createdAt: state.createdAt ? new Date(state.createdAt) : now, + updatedAt: now, + } as any, + { upsert: true }, + ); + + this.log(`Set workflow state ${executionId}`); + } + + /** + * Update workflow state (partial update) + */ + async updateWorkflowState( + executionId: string, + updates: Partial, + ): Promise { + await this.initPromise; + + const workflowStatesCollection = this.getCollection("workflow_states"); + + const updateDoc: any = { + updatedAt: new Date(), + }; + + if (updates.status !== undefined) { + updateDoc.status = updates.status; + } + + if (updates.suspension !== undefined) { + updateDoc.suspension = updates.suspension; + } + + if (updates.events !== undefined) { + updateDoc.events = updates.events; + } + + if (updates.output !== undefined) { + updateDoc.output = updates.output; + } + + if (updates.cancellation !== undefined) { + updateDoc.cancellation = updates.cancellation; + } + + if (updates.metadata !== undefined) { + updateDoc.metadata = updates.metadata; + } + + await workflowStatesCollection.updateOne({ _id: executionId } as any, { $set: updateDoc }); + + this.log(`Updated workflow state ${executionId}`); + } + + /** + * Get suspended workflow states + */ + async getSuspendedWorkflowStates(workflowId: string): Promise { + await this.initPromise; + + const workflowStatesCollection = this.getCollection("workflow_states"); + + const states = await workflowStatesCollection + .find({ workflowId, status: "suspended" } as any) + .sort({ createdAt: -1 }) + .toArray(); + + return states.map((state: any) => ({ + id: state._id, + workflowId: state.workflowId, + workflowName: state.workflowName, + status: state.status, + suspension: state.suspension, + events: state.events, + output: state.output, + cancellation: state.cancellation, + userId: state.userId, + conversationId: state.conversationId, + metadata: state.metadata, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + })); + } +} diff --git a/packages/mongodb/tsconfig.json b/packages/mongodb/tsconfig.json new file mode 100644 index 000000000..fb0c6c836 --- /dev/null +++ b/packages/mongodb/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "__tests__/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/mongodb/tsup.config.ts b/packages/mongodb/tsup.config.ts new file mode 100644 index 000000000..ae0c8c510 --- /dev/null +++ b/packages/mongodb/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; +import { markAsExternalPlugin } from "../shared/tsup-plugins/mark-as-external"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + splitting: false, + sourcemap: true, + clean: false, + target: "es2022", + outDir: "dist", + minify: false, + dts: true, + external: ["@voltagent/core", "ai", "mongodb"], + esbuildPlugins: [markAsExternalPlugin], + esbuildOptions(options) { + options.keepNames = true; + return options; + }, +}); diff --git a/packages/mongodb/vitest.config.mts b/packages/mongodb/vitest.config.mts new file mode 100644 index 000000000..cc2bb3aa7 --- /dev/null +++ b/packages/mongodb/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.{test,spec}.ts"], + exclude: ["src/**/*.integration.test.ts", "node_modules", "dist"], + }, +}); diff --git a/packages/mongodb/vitest.integration.config.mts b/packages/mongodb/vitest.integration.config.mts new file mode 100644 index 000000000..fc5c2ab76 --- /dev/null +++ b/packages/mongodb/vitest.integration.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.integration.test.ts"], + testTimeout: 30000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 012104f05..1ad37a95f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4004,6 +4004,22 @@ importers: specifier: ^3.25.76 version: 3.25.76 + packages/mongodb: + dependencies: + mongodb: + specifier: ^7.0.0 + version: 7.0.0 + devDependencies: + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + '@voltagent/core': + specifier: ^2.0.2 + version: link:../core + ai: + specifier: ^6.0.0 + version: 6.0.3(zod@4.3.5) + packages/postgres: dependencies: '@voltagent/internal': @@ -10979,6 +10995,12 @@ packages: os-filter-obj: 2.0.0 dev: true + /@mongodb-js/saslprep@1.4.5: + resolution: {integrity: sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==} + dependencies: + sparse-bitfield: 3.0.3 + dev: false + /@mswjs/interceptors@0.40.0: resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} @@ -16420,13 +16442,8 @@ packages: resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} dev: true - /@rolldown/pluginutils@1.0.0-beta.58: - resolution: {integrity: sha512-qWhDs6yFGR5xDfdrwiSa3CWGIHxD597uGE/A9xGqytBjANvh4rLCTTkq7szhMV4+Ygh+PMS90KVJ8xWG/TkX4w==} - dev: false - /@rolldown/pluginutils@1.0.0-rc.2: resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} - dev: true /@rollup/plugin-alias@5.1.1(rollup@4.50.2): resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} @@ -19120,6 +19137,16 @@ packages: resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} dev: false + /@types/webidl-conversions@7.0.3: + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + dev: false + + /@types/whatwg-url@13.0.0: + resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} + dependencies: + '@types/webidl-conversions': 7.0.3 + dev: false + /@types/wrap-ansi@3.0.0: resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} dev: false @@ -19614,7 +19641,7 @@ packages: '@babel/core': 7.28.5 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.58 + '@rolldown/pluginutils': 1.0.0-rc.2 '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) vite: 7.2.7(@types/node@24.2.1)(jiti@2.6.1) vue: 3.5.22(typescript@5.9.3) @@ -21781,6 +21808,11 @@ packages: node-int64: 0.4.0 dev: true + /bson@7.1.1: + resolution: {integrity: sha512-TtJgBB+QyOlWjrbM+8bRgH84VM/xrDjyBFgSgGrfZF4xvt6gbEDtcswm27Tn9F9TWsjQybxT8b8VpCP/oJK4Dw==} + engines: {node: '>=20.19.0'} + dev: false + /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: true @@ -30390,6 +30422,10 @@ packages: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} dev: true + /memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + dev: false + /meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -31223,6 +31259,46 @@ packages: micro-memoize: 4.1.3 dev: true + /mongodb-connection-string-url@7.0.1: + resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} + engines: {node: '>=20.19.0'} + dependencies: + '@types/whatwg-url': 13.0.0 + whatwg-url: 14.2.0 + dev: false + + /mongodb@7.0.0: + resolution: {integrity: sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: '>=7.0.0 <7.1.0' + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + dependencies: + '@mongodb-js/saslprep': 1.4.5 + bson: 7.1.1 + mongodb-connection-string-url: 7.0.1 + dev: false + /motion-dom@12.23.12: resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} dependencies: @@ -36266,6 +36342,12 @@ packages: /space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + /sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + dependencies: + memory-pager: 1.5.0 + dev: false + /spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} dependencies: @@ -37373,6 +37455,13 @@ packages: punycode: 2.3.1 dev: true + /tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + dependencies: + punycode: 2.3.1 + dev: false + /tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -39529,7 +39618,6 @@ packages: /webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} - dev: true /webpack-node-externals@3.0.0: resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} @@ -39621,6 +39709,14 @@ packages: webidl-conversions: 7.0.0 dev: true + /whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + dev: false + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: