diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 67297827fb..6a3c0e8b77 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -5180,6 +5180,9 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.viewData"), action: toggle, icon: TableProperties }); items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + if (canRenameMongoCollection.value) { + items.push({ label: t("contextMenu.renameObject"), action: openRenameMongoCollectionDialog, icon: Pencil, shortcut: shortcutRename }); + } if (canDropMilvusCollection.value) { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.dropCollection"), action: dropMilvusCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const }); diff --git a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.rename.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.rename.spec.ts index 5832703341..178ea6aebf 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.rename.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.rename.spec.ts @@ -8,7 +8,11 @@ const mocks = vi.hoisted(() => ({ toast: vi.fn(), ensureConnected: vi.fn().mockResolvedValue(undefined), loadMongoCollections: vi.fn().mockResolvedValue(undefined), + loadVectorCollections: vi.fn().mockResolvedValue(undefined), mongoRenameCollection: vi.fn(), + vectorRenameCollection: vi.fn(), + replacePinnedTreeNode: vi.fn(), + removeTreeNode: vi.fn(), getConfig: vi.fn(() => ({ id: "conn-1", name: "Mongo", @@ -21,7 +25,8 @@ const mocks = vi.hoisted(() => ({ })), })); -vi.mock("vue-i18n", () => ({ +vi.mock("vue-i18n", async (importOriginal) => ({ + ...(await importOriginal()), useI18n: () => ({ t: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key), }), @@ -36,12 +41,16 @@ vi.mock("@/stores/connectionStore", () => ({ getConfig: mocks.getConfig, ensureConnected: mocks.ensureConnected, loadMongoCollections: mocks.loadMongoCollections, + loadVectorCollections: mocks.loadVectorCollections, + replacePinnedTreeNode: mocks.replacePinnedTreeNode, + removeTreeNode: mocks.removeTreeNode, treeNodes: [], }), })); vi.mock("@/lib/backend/api", () => ({ mongoRenameCollection: (...args: unknown[]) => mocks.mongoRenameCollection(...args), + vectorRenameCollection: (...args: unknown[]) => mocks.vectorRenameCollection(...args), mongoDropCollection: vi.fn(), mongoDropDatabase: vi.fn(), mongoDropIndexes: vi.fn(), @@ -156,3 +165,131 @@ describe("confirmRenameMongoCollection existing target failure", () => { expect(mocks.toast).not.toHaveBeenCalled(); }); }); + +function milvusCollectionNode(): TreeNode { + return { + id: "milvus-1:__vector_collection:analytics:events", + label: "events", + type: "vector-collection", + connectionId: "milvus-1", + database: "analytics", + isExpanded: false, + }; +} + +describe("Milvus collection rename", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + mocks.getConfig.mockReturnValue({ + id: "milvus-1", + name: "Milvus", + db_type: "milvus", + host: "localhost", + port: 19530, + username: "", + password: "", + } as any); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.loadVectorCollections.mockResolvedValue(undefined); + mocks.vectorRenameCollection.mockResolvedValue(undefined); + sidebarFormTarget.value = milvusCollectionNode(); + }); + + it("renames a Milvus collection and refreshes its database node", async () => { + renameMongoCollectionName.value = "events_archive"; + showRenameMongoCollectionDialog.value = true; + const feature = useSidebarDatabaseSpecificMutationRuntime({ + activeNode: shallowRef(milvusCollectionNode()), + connectionStore: { + getConfig: mocks.getConfig, + ensureConnected: mocks.ensureConnected, + loadVectorCollections: mocks.loadVectorCollections, + replacePinnedTreeNode: mocks.replacePinnedTreeNode, + removeTreeNode: mocks.removeTreeNode, + treeNodes: [], + } as any, + }); + + expect(feature.canRenameMongoCollection.value).toBe(true); + expect(feature.canCloneMongoCollection.value).toBe(false); + await feature.confirmRenameMongoCollection(); + + expect(mocks.vectorRenameCollection).toHaveBeenCalledWith("milvus-1", "analytics", "events", "events_archive"); + expect(mocks.loadVectorCollections).toHaveBeenCalledWith("milvus-1", "analytics"); + expect(showRenameMongoCollectionDialog.value).toBe(false); + }); + + it("preserves a pinned Milvus collection after its rename refresh", async () => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => storage.set(key, value)), + removeItem: vi.fn((key: string) => storage.delete(key)), + }); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + + const { useConnectionStore } = await vi.importActual("@/stores/connectionStore"); + const store = useConnectionStore(); + const originalNode = milvusCollectionNode(); + const renamedNode: TreeNode = { + ...originalNode, + id: "milvus-1:__vector_collection:analytics:events_archive", + label: "events_archive", + }; + const databaseNode: TreeNode = { + id: "milvus-1:analytics", + label: "analytics", + type: "vector-database", + connectionId: "milvus-1", + database: "analytics", + isExpanded: true, + children: [originalNode], + }; + store.connections = [mocks.getConfig() as any]; + store.treeNodes = [databaseNode]; + store.toggleTreeNodePin(originalNode); + vi.spyOn(store, "ensureConnected").mockResolvedValue(undefined); + vi.spyOn(store, "loadVectorCollections").mockImplementation(async () => { + databaseNode.children = [renamedNode]; + }); + + sidebarFormTarget.value = originalNode; + renameMongoCollectionName.value = "events_archive"; + showRenameMongoCollectionDialog.value = true; + const feature = useSidebarDatabaseSpecificMutationRuntime({ + activeNode: shallowRef(originalNode), + connectionStore: store, + }); + + await feature.confirmRenameMongoCollection(); + + expect(store.isTreeNodePinned(renamedNode)).toBe(true); + expect(store.isTreeNodePinned(originalNode)).toBe(false); + }); + + it("keeps a successful rename successful when metadata refresh fails", async () => { + mocks.loadVectorCollections.mockRejectedValueOnce(new Error("metadata timeout")); + renameMongoCollectionName.value = "events_archive"; + showRenameMongoCollectionDialog.value = true; + const node = milvusCollectionNode(); + const feature = useSidebarDatabaseSpecificMutationRuntime({ + activeNode: shallowRef(node), + connectionStore: { + getConfig: mocks.getConfig, + ensureConnected: mocks.ensureConnected, + loadVectorCollections: mocks.loadVectorCollections, + replacePinnedTreeNode: mocks.replacePinnedTreeNode, + removeTreeNode: mocks.removeTreeNode, + treeNodes: [], + } as any, + }); + + await feature.confirmRenameMongoCollection(); + + expect(mocks.vectorRenameCollection).toHaveBeenCalledOnce(); + expect(showRenameMongoCollectionDialog.value).toBe(false); + expect(mocks.removeTreeNode).toHaveBeenCalledWith(node.id); + expect(renameMongoCollectionError.value).toBe(""); + }); +}); diff --git a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts index 1014acf25b..ceab7d9b79 100644 --- a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts @@ -136,8 +136,12 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba return usesAnyMongoDriver(node); } + function canMutateMilvusCollectionNode(node: TreeNode): boolean { + return node.type === "vector-collection" && !!node.connectionId && !!node.database && connectionStore.getConfig(node.connectionId)?.db_type === "milvus"; + } + function canRenameMongoCollectionNode(node: TreeNode): boolean { - return canMutateMongoCollectionNode(node) && usesNativeMongoDriver(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node)); + return canMutateMilvusCollectionNode(node) || (canMutateMongoCollectionNode(node) && usesNativeMongoDriver(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node))); } function canCloneMongoCollectionNode(node: TreeNode): boolean { @@ -169,7 +173,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba renameMongoCollectionPreview.value = ""; return; } - renameMongoCollectionPreview.value = mongoRenameCollectionPreview(node.database, node.label, newName); + renameMongoCollectionPreview.value = canMutateMilvusCollectionNode(node) ? `POST /v2/vectordb/collections/rename\n${JSON.stringify({ dbName: node.database, collectionName: node.label, newCollectionName: newName })}` : mongoRenameCollectionPreview(node.database, node.label, newName); } watch([showRenameMongoCollectionDialog, renameMongoCollectionName, () => activeNode.value.label, () => activeNode.value.database], () => { @@ -189,17 +193,37 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba await runMongoSidebarMutation({ connection: connectionStore.getConfig(connectionId), database, - reviewText: mongoRenameCollectionPreview(database, oldName, newName), + reviewText: canMutateMilvusCollectionNode(node) ? `POST /v2/vectordb/collections/rename\n${JSON.stringify({ dbName: database, collectionName: oldName, newCollectionName: newName })}` : mongoRenameCollectionPreview(database, oldName, newName), source: t("production.sourceSidebar"), loading: renameMongoCollectionLoading, beforeExecute: () => connectionStore.ensureConnected(connectionId), execute: async () => { - await api.mongoRenameCollection(connectionId, database, oldName, newName); - await connectionStore.loadMongoCollections(connectionId, database); + if (canMutateMilvusCollectionNode(node)) { + await api.vectorRenameCollection(connectionId, database, oldName, newName); + } else { + await api.mongoRenameCollection(connectionId, database, oldName, newName); + } }, - onSuccess: () => { + onSuccess: async () => { toast(t("contextMenu.renameObjectSuccess", { oldName, newName }), 3000); showRenameMongoCollectionDialog.value = false; + try { + if (canMutateMilvusCollectionNode(node)) { + await connectionStore.loadVectorCollections(connectionId, database); + connectionStore.replacePinnedTreeNode(node, { + ...node, + id: `${connectionId}:__vector_collection:${database}:${newName}`, + label: newName, + objectName: newName, + tableName: newName, + }); + } else { + await connectionStore.loadMongoCollections(connectionId, database); + } + } catch (error) { + connectionStore.removeTreeNode(node.id); + toast(t("contextMenu.objectDropRefreshFailed", { message: translateBackendError(t, errorMessage(error)) }), 5000); + } }, onError: (error) => { renameMongoCollectionError.value = translateBackendError(t, errorMessage(error)); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 640bca0c2d..70f6f0e1b4 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -707,6 +707,7 @@ export const documentDeleteGridFsFile = forward("documentDeleteGridFsFile"); export const vectorGetCollectionDetail = forward("vectorGetCollectionDetail"); export const vectorDropDatabase = forward("vectorDropDatabase"); export const vectorDropCollection = forward("vectorDropCollection"); +export const vectorRenameCollection = forward("vectorRenameCollection"); export const mongoCreateDatabase = forward("mongoCreateDatabase"); export const mongoDropDatabase = forward("mongoDropDatabase"); export const mongoDropCollection = forward("mongoDropCollection"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 982d31513f..8e24ece2f4 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -3633,7 +3633,7 @@ export async function vectorListCollections(connectionId: string, database?: str } export async function vectorGetCollectionDetail(connectionId: string, database: string, collection: string): Promise { - return post("/api/mongo/vector-collection-detail", { + return post("/api/vector/collection-detail", { connectionId, database, collection, @@ -3648,6 +3648,10 @@ export async function vectorDropCollection(connectionId: string, database: strin await post("/api/vector/drop-collection", { connectionId, database, collection }); } +export async function vectorRenameCollection(connectionId: string, database: string, collection: string, newName: string): Promise { + await post("/api/vector/rename-collection", { connectionId, database, collection, newName }); +} + export async function mongoFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string): Promise { return documentFindDocuments(connectionId, database, collection, skip, limit, filter, projection, sort, collation, executionId); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 8b6553c68f..c82864f04c 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -3565,6 +3565,10 @@ export async function vectorDropCollection(connectionId: string, database: strin return invoke("vector_drop_collection", { connectionId, database, collection }); } +export async function vectorRenameCollection(connectionId: string, database: string, collection: string, newName: string): Promise { + return invoke("vector_rename_collection", { connectionId, database, collection, newName }); +} + export async function elasticsearchListIndices(connectionId: string): Promise { const collections = await documentListCollections(connectionId, "default"); return collections.map((c) => c.name); diff --git a/crates/dbx-core/src/db/vector_driver.rs b/crates/dbx-core/src/db/vector_driver.rs index ce82bd8909..a80ae1e782 100644 --- a/crates/dbx-core/src/db/vector_driver.rs +++ b/crates/dbx-core/src/db/vector_driver.rs @@ -264,6 +264,50 @@ pub async fn drop_collection(client: &VectorClient, database: &str, collection: .map(|_| ()) } +fn validate_milvus_collection_rename( + client: &VectorClient, + database: &str, + source_collection: &str, + target_collection: &str, +) -> Result<(), String> { + if client.kind != VectorDbKind::Milvus { + return Err("Collection rename is only supported for Milvus connections".to_string()); + } + if database.is_empty() { + return Err("Database name must not be empty".to_string()); + } + if source_collection.is_empty() { + return Err("Source collection name must not be empty".to_string()); + } + if target_collection.is_empty() { + return Err("Target collection name must not be empty".to_string()); + } + if source_collection == target_collection { + return Err("Source and target collection names must differ".to_string()); + } + Ok(()) +} + +/// Rename a Milvus collection through the v2 REST API. +pub async fn rename_collection( + client: &VectorClient, + database: &str, + collection: &str, + new_name: &str, +) -> Result<(), String> { + validate_milvus_collection_rename(client, database, collection, new_name)?; + send_json( + client.post("/v2/vectordb/collections/rename").json(&serde_json::json!({ + "dbName": database, + "collectionName": collection, + "newCollectionName": new_name, + })), + client.kind, + ) + .await + .map(|_| ()) +} + async fn list_milvus_databases(client: &VectorClient) -> Result, String> { // Older Milvus versions (pre-2.2) do not expose the databases endpoint; fall back to the // configured database (or "default") so the connection stays browsable instead of failing the whole tree load. @@ -966,9 +1010,9 @@ fn format_reqwest_error(err: &reqwest::Error) -> String { mod tests { use super::{ chroma_get_response_to_rows, default_collection_query, milvus_collection_schema, milvus_database_names, - rest_query_result, starts_with_http_method, test_connection, test_connection_request, values_to_query_result, - vector_auth, weaviate_collection_names_from_schema, weaviate_vector_dimension_from_graphql, CollectionInfo, - VectorAuth, VectorClient, VectorDbKind, + rename_collection, rest_query_result, starts_with_http_method, test_connection, test_connection_request, + values_to_query_result, vector_auth, weaviate_collection_names_from_schema, + weaviate_vector_dimension_from_graphql, CollectionInfo, VectorAuth, VectorClient, VectorDbKind, }; use serde_json::{json, Value}; use std::time::{Duration, Instant}; @@ -1004,9 +1048,31 @@ mod tests { let (request_tx, request_rx) = tokio::sync::oneshot::channel(); let server = tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); - let mut request = [0_u8; 8192]; - let read = stream.read(&mut request).await.unwrap(); - request_tx.send(String::from_utf8_lossy(&request[..read]).into_owned()).unwrap(); + let mut request = Vec::new(); + loop { + let mut chunk = [0_u8; 2048]; + let read = stream.read(&mut chunk).await.unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length").then(|| value.trim().to_owned()) + }) + .and_then(|length| length.parse::().ok()) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + request_tx.send(String::from_utf8_lossy(&request).into_owned()).unwrap(); let response = format!( "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", body.len(), @@ -1295,6 +1361,36 @@ mod tests { assert!(request.headers().get("x-chroma-token").is_none()); } + #[tokio::test] + async fn milvus_rename_collection_uses_v2_api_and_expected_names() { + let (url, request_rx, server) = spawn_recording_json_response_server(json!({ "code": 0 })).await; + let client = VectorClient::new(VectorDbKind::Milvus, &url, None, None, false, Duration::from_secs(1)); + + rename_collection(&client, "analytics", "events", "events_archive").await.unwrap(); + let request = request_rx.await.unwrap(); + + assert!(request.starts_with("POST /v2/vectordb/collections/rename HTTP/1.1\r\n")); + let request_body: Value = serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!( + request_body, + json!({ + "dbName": "analytics", + "collectionName": "events", + "newCollectionName": "events_archive", + }) + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn milvus_collection_rename_rejects_invalid_targets_before_sending() { + let client = + VectorClient::new(VectorDbKind::Milvus, "http://127.0.0.1:1", None, None, false, Duration::from_secs(1)); + + assert!(rename_collection(&client, "default", "events", "events").await.unwrap_err().contains("differ")); + assert!(rename_collection(&client, "default", "events", "").await.unwrap_err().contains("must not be empty")); + } + #[test] fn chroma_collection_paths_encode_every_dynamic_segment() { let client = VectorClient::new( diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 49bd5d7f1f..5d22ec08d5 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1118,6 +1118,24 @@ pub async fn drop_vector_collection_core( db::vector_driver::drop_collection(&client, database, collection).await } +pub async fn rename_vector_collection_core( + state: &AppState, + connection_id: &str, + database: &str, + collection: &str, + new_name: &str, +) -> Result<(), String> { + let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; + let client = { + let connections = state.connections.read().await; + match connections.get(&pool_key) { + Some(PoolKind::VectorDb(client)) => client.clone(), + _ => return Err("Not a vector database connection".to_string()), + } + }; + db::vector_driver::rename_collection(&client, database, collection, new_name).await +} + pub async fn get_table_comment_core( state: &AppState, connection_id: &str, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 6cb21e6031..9a33c6f2fd 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -786,9 +786,11 @@ async fn main() { // MongoDB .route("/mongo/list-databases", post(routes::mongo::list_databases)) .route("/mongo/list-collections", post(routes::mongo::list_collections)) - .route("/mongo/vector-collection-detail", post(routes::mongo::vector_collection_detail)) - .route("/vector/drop-database", post(routes::mongo::vector_drop_database)) - .route("/vector/drop-collection", post(routes::mongo::vector_drop_collection)) + .route("/mongo/vector-collection-detail", post(routes::vector::collection_detail)) + .route("/vector/collection-detail", post(routes::vector::collection_detail)) + .route("/vector/drop-database", post(routes::vector::drop_database)) + .route("/vector/drop-collection", post(routes::vector::drop_collection)) + .route("/vector/rename-collection", post(routes::vector::rename_collection)) .route("/mongo/create-database", post(routes::mongo::create_database)) .route("/mongo/drop-database", post(routes::mongo::drop_database)) .route("/mongo/drop-collection", post(routes::mongo::drop_collection)) diff --git a/crates/dbx-web/src/routes/mod.rs b/crates/dbx-web/src/routes/mod.rs index 3586461803..78aa76a177 100644 --- a/crates/dbx-web/src/routes/mod.rs +++ b/crates/dbx-web/src/routes/mod.rs @@ -40,4 +40,5 @@ pub mod text_export; pub mod transfer; pub mod tunnel_profiles; pub mod update; +pub mod vector; pub mod zookeeper; diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 8b63016d6d..12b6452523 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -344,57 +344,6 @@ pub async fn list_collections( Ok(Json(result)) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VectorCollectionDetailRequest { - pub connection_id: String, - pub database: String, - pub collection: String, -} - -pub async fn vector_collection_detail( - State(state): State>, - Json(req): Json, -) -> Result, AppError> { - let result = dbx_core::schema::get_vector_collection_detail_core( - &state.app, - &req.connection_id, - &req.database, - &req.collection, - ) - .await - .map_err(AppError::from)?; - Ok(Json(result)) -} - -pub async fn vector_drop_database( - State(state): State>, - headers: HeaderMap, - Json(req): Json, -) -> Result, AppError> { - super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Drop database") - .await?; - ensure_writable(&state.app, &req.connection_id, "Drop database").await?; - dbx_core::schema::drop_vector_database_core(&state.app, &req.connection_id, &req.database) - .await - .map_err(AppError::from)?; - Ok(Json(serde_json::json!({ "ok": true }))) -} - -pub async fn vector_drop_collection( - State(state): State>, - headers: HeaderMap, - Json(req): Json, -) -> Result, AppError> { - super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Drop collection") - .await?; - ensure_writable(&state.app, &req.connection_id, "Drop collection").await?; - dbx_core::schema::drop_vector_collection_core(&state.app, &req.connection_id, &req.database, &req.collection) - .await - .map_err(AppError::from)?; - Ok(Json(serde_json::json!({ "ok": true }))) -} - pub async fn create_database( State(state): State>, Json(req): Json, diff --git a/crates/dbx-web/src/routes/vector.rs b/crates/dbx-web/src/routes/vector.rs new file mode 100644 index 0000000000..dcad1cdec4 --- /dev/null +++ b/crates/dbx-web/src/routes/vector.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::http::HeaderMap; +use axum::Json; +use serde::Deserialize; + +use crate::error::AppError; +use crate::state::WebState; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VectorCollectionRequest { + pub connection_id: String, + pub database: String, + pub collection: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VectorDatabaseRequest { + pub connection_id: String, + pub database: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VectorRenameCollectionRequest { + pub connection_id: String, + pub database: String, + pub collection: String, + pub new_name: String, +} + +async fn ensure_writable(state: &WebState, connection_id: &str, action: &str) -> Result<(), AppError> { + if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, connection_id).await { + return Err(AppError::from(format!( + "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", + name, action + ))); + } + Ok(()) +} + +pub async fn collection_detail( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + dbx_core::schema::get_vector_collection_detail_core(&state.app, &req.connection_id, &req.database, &req.collection) + .await + .map(Json) + .map_err(AppError::from) +} + +pub async fn drop_database( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Drop database") + .await?; + ensure_writable(&state, &req.connection_id, "Drop database").await?; + dbx_core::schema::drop_vector_database_core(&state.app, &req.connection_id, &req.database) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn drop_collection( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Drop collection") + .await?; + ensure_writable(&state, &req.connection_id, "Drop collection").await?; + dbx_core::schema::drop_vector_collection_core(&state.app, &req.connection_id, &req.database, &req.collection) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn rename_collection( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Rename collection") + .await?; + ensure_writable(&state, &req.connection_id, "Rename collection").await?; + dbx_core::schema::rename_vector_collection_core( + &state.app, + &req.connection_id, + &req.database, + &req.collection, + &req.new_name, + ) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 127927e8ef..ff8109a2fe 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -56,6 +56,7 @@ pub mod transfer; pub mod tunnel_profiles; pub mod update; mod update_portable; +pub mod vector_cmd; pub mod window_controls; pub mod xlsx_export; pub mod zookeeper_cmd; diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index a6828f96ad..23596a60a6 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -45,16 +45,6 @@ pub async fn mongo_list_collections( dbx_core::mongo_ops::mongo_list_collections_core(&state, &connection_id, &database).await } -#[tauri::command] -pub async fn vector_collection_detail( - state: State<'_, Arc>, - connection_id: String, - database: String, - collection: String, -) -> Result { - dbx_core::schema::get_vector_collection_detail_core(&state, &connection_id, &database, &collection).await -} - #[tauri::command] pub async fn mongo_create_database( state: State<'_, Arc>, @@ -86,27 +76,6 @@ pub async fn mongo_drop_collection( dbx_core::mongo_ops::mongo_drop_collection_core(&state, &connection_id, &database, &collection).await } -#[tauri::command] -pub async fn vector_drop_database( - state: State<'_, Arc>, - connection_id: String, - database: String, -) -> Result<(), String> { - ensure_connection_writable(&state, &connection_id, "Drop database").await?; - dbx_core::schema::drop_vector_database_core(&state, &connection_id, &database).await -} - -#[tauri::command] -pub async fn vector_drop_collection( - state: State<'_, Arc>, - connection_id: String, - database: String, - collection: String, -) -> Result<(), String> { - ensure_connection_writable(&state, &connection_id, "Drop collection").await?; - dbx_core::schema::drop_vector_collection_core(&state, &connection_id, &database, &collection).await -} - #[tauri::command] pub async fn mongo_rename_collection( state: State<'_, Arc>, diff --git a/src-tauri/src/commands/vector_cmd.rs b/src-tauri/src/commands/vector_cmd.rs new file mode 100644 index 0000000000..bdac9aea24 --- /dev/null +++ b/src-tauri/src/commands/vector_cmd.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use tauri::State; + +use crate::commands::connection::{ensure_connection_writable, AppState}; + +#[tauri::command] +pub async fn vector_collection_detail( + state: State<'_, Arc>, + connection_id: String, + database: String, + collection: String, +) -> Result { + dbx_core::schema::get_vector_collection_detail_core(&state, &connection_id, &database, &collection).await +} + +#[tauri::command] +pub async fn vector_drop_database( + state: State<'_, Arc>, + connection_id: String, + database: String, +) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "Drop database").await?; + dbx_core::schema::drop_vector_database_core(&state, &connection_id, &database).await +} + +#[tauri::command] +pub async fn vector_drop_collection( + state: State<'_, Arc>, + connection_id: String, + database: String, + collection: String, +) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "Drop collection").await?; + dbx_core::schema::drop_vector_collection_core(&state, &connection_id, &database, &collection).await +} + +#[tauri::command] +pub async fn vector_rename_collection( + state: State<'_, Arc>, + connection_id: String, + database: String, + collection: String, + new_name: String, +) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "Rename collection").await?; + dbx_core::schema::rename_vector_collection_core(&state, &connection_id, &database, &collection, &new_name).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d9311f0b0..edbd776d46 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2012,12 +2012,13 @@ pub fn run() { commands::sqlite_backup::backup_sqlite_database, commands::mongo_cmd::mongo_list_databases, commands::mongo_cmd::mongo_list_collections, - commands::mongo_cmd::vector_collection_detail, + commands::vector_cmd::vector_collection_detail, commands::mongo_cmd::mongo_create_database, commands::mongo_cmd::mongo_drop_database, commands::mongo_cmd::mongo_drop_collection, - commands::mongo_cmd::vector_drop_database, - commands::mongo_cmd::vector_drop_collection, + commands::vector_cmd::vector_drop_database, + commands::vector_cmd::vector_drop_collection, + commands::vector_cmd::vector_rename_collection, commands::mongo_cmd::mongo_rename_collection, commands::mongo_cmd::mongo_clone_collection, commands::docs::docs_collect_snapshot,