From 1fb3e2816b892d6413923d6a6305d3106492153b Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Wed, 22 Jul 2026 13:43:39 +0800 Subject: [PATCH] feat(ai): add custom prompt templates --- apps/desktop/src/App.vue | 4 + .../src/components/editor/AiAssistant.vue | 121 +++++- .../editor/EditorSettingsDialog.vue | 227 +++++++++- apps/desktop/src/i18n/locales/en.ts | 34 ++ apps/desktop/src/i18n/locales/es.ts | 34 ++ apps/desktop/src/i18n/locales/it.ts | 34 ++ apps/desktop/src/i18n/locales/ja.ts | 34 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 34 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 34 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 34 ++ apps/desktop/src/lib/ai/ai.ts | 47 +- apps/desktop/src/lib/backend/api.ts | 8 + apps/desktop/src/lib/backend/http.ts | 36 ++ apps/desktop/src/lib/backend/tauri.ts | 30 ++ .../desktop/src/stores/promptTemplateStore.ts | 71 +++ apps/desktop/src/types/promptTemplate.ts | 17 + crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/prompt_template.rs | 11 + crates/dbx-core/src/storage.rs | 405 ++++++++++++++++++ crates/dbx-web/src/error.rs | 19 +- crates/dbx-web/src/main.rs | 10 + crates/dbx-web/src/routes/agents.rs | 64 +-- crates/dbx-web/src/routes/ai.rs | 32 +- crates/dbx-web/src/routes/app_settings.rs | 10 +- crates/dbx-web/src/routes/cloud_sync.rs | 54 +-- crates/dbx-web/src/routes/connection.rs | 53 +-- crates/dbx-web/src/routes/data_compare.rs | 12 +- crates/dbx-web/src/routes/database_export.rs | 10 +- crates/dbx-web/src/routes/document_store.rs | 57 +-- crates/dbx-web/src/routes/etcd.rs | 11 +- crates/dbx-web/src/routes/history.rs | 13 +- crates/dbx-web/src/routes/layout.rs | 4 +- crates/dbx-web/src/routes/mcp_policy.rs | 32 +- crates/dbx-web/src/routes/mod.rs | 1 + crates/dbx-web/src/routes/mongo.rs | 74 ++-- crates/dbx-web/src/routes/mq.rs | 122 +++--- crates/dbx-web/src/routes/nacos.rs | 36 +- crates/dbx-web/src/routes/plugins.rs | 2 +- crates/dbx-web/src/routes/prompt_template.rs | 97 +++++ crates/dbx-web/src/routes/query.rs | 46 +- .../dbx-web/src/routes/query_result_export.rs | 12 +- crates/dbx-web/src/routes/redis.rs | 77 ++-- crates/dbx-web/src/routes/saved_sql.rs | 12 +- crates/dbx-web/src/routes/schema.rs | 125 +++--- crates/dbx-web/src/routes/schema_cache.rs | 6 +- crates/dbx-web/src/routes/sql_file.rs | 30 +- crates/dbx-web/src/routes/table_export.rs | 12 +- crates/dbx-web/src/routes/table_import.rs | 46 +- crates/dbx-web/src/routes/transfer.rs | 30 +- crates/dbx-web/src/routes/tunnel_profiles.rs | 6 +- crates/dbx-web/src/routes/update.rs | 7 +- crates/dbx-web/src/routes/zookeeper.rs | 11 +- .../app-tests/aiAssistantSendGuard.test.ts | 112 +++++ packages/app-tests/aiCustomPrompt.test.ts | 236 ++++++++++ .../app-tests/promptTemplateStore.test.ts | 144 +++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/prompt_template.rs | 36 ++ src-tauri/src/lib.rs | 5 + 58 files changed, 2381 insertions(+), 502 deletions(-) create mode 100644 apps/desktop/src/stores/promptTemplateStore.ts create mode 100644 apps/desktop/src/types/promptTemplate.ts create mode 100644 crates/dbx-core/src/prompt_template.rs create mode 100644 crates/dbx-web/src/routes/prompt_template.rs create mode 100644 packages/app-tests/aiAssistantSendGuard.test.ts create mode 100644 packages/app-tests/aiCustomPrompt.test.ts create mode 100644 packages/app-tests/promptTemplateStore.test.ts create mode 100644 src-tauri/src/commands/prompt_template.rs diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 5a4bbd6700..b7bf7ffb34 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -16,6 +16,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { enforceRightSidebarPanelExclusivity, RIGHT_SIDEBAR_PANEL_IDS, transitionRightSidebarPanels, useSettingsStore, type RightSidebarPanelId, type RightSidebarPanelState } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; +import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useAppUpdater } from "@/composables/useAppUpdater"; @@ -123,6 +124,7 @@ const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); const savedSqlStore = useSavedSqlStore(); +const promptTemplateStore = usePromptTemplateStore(); connectionStore.setBeforeConnectHandler((config) => ensureJdbcxRuntimeDrivers(config, api).then(() => undefined)); const { message: toastMessage, visible: toastVisible, toast } = useToast(); const { isDark, themeMode, applyTheme, setThemeMode } = useTheme(); @@ -1905,6 +1907,8 @@ async function initApp() { console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`); await settingsStore.initDesktopSettings().catch(() => {}); + void promptTemplateStore.init(); + void Promise.all([initSavedSqlEditorPositions(), savedSqlStore.initFromStorage()]) .then(() => { console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`); diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 272f866abf..57c8edb19c 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -47,15 +47,17 @@ import { useSettingsStore, AI_PROVIDER_PRESETS, normalizeAiConfig } from "@/stor import AiProviderLogo from "@/components/icons/AiProviderLogo.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; +import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; import { connectionIconType } from "@/lib/connection/connectionPresentation"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue"; import { useQueryStore } from "@/stores/queryStore"; import { useToast } from "@/composables/useToast"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; -import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext } from "@/lib/ai/ai"; +import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai"; import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; import { normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort"; +import { ACTIVE_TEMPLATES_TOTAL_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate"; import type { AgentEvent } from "@/lib/backend/tauri"; import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan"; @@ -85,6 +87,7 @@ const { t } = useI18n(); const settings = useSettingsStore(); const connectionStore = useConnectionStore(); const savedSqlStore = useSavedSqlStore(); +const promptTemplateStore = usePromptTemplateStore(); const queryStore = useQueryStore(); const { openTableTarget } = useNavigationTargets({ showFieldLineageDialog: ref(false), @@ -148,6 +151,67 @@ const currentSessionId = ref(""); const conversationId = ref(""); const conversations = ref([]); const showConversationList = ref(false); +const showTemplateSelector = ref(false); + +// Prompt template selection (panel-session scope) +const activeTemplateIds = ref([]); +const activeTemplates = computed(() => promptTemplateStore.templates.filter((t) => activeTemplateIds.value.includes(t.id))); + +watch( + () => promptTemplateStore.templates, + (templates) => { + const availableIds = new Set(templates.map((template) => template.id)); + activeTemplateIds.value = activeTemplateIds.value.filter((id) => availableIds.has(id)); + }, +); + +// Retry store load on selector open if prior init failed (e.g. backend not yet ready at mount) +watch(showTemplateSelector, (open) => { + if (open) void promptTemplateStore.ensureLoaded(); +}); + +// Reset template selection when the user switches to a different connection or database — +// a new database context warrants a fresh selection of scenario templates. +watch( + // Return a stable primitive key: a fresh array literal is never Object.is-equal to the + // previous one, so a getter returning `[id, database]` fires on every dependency + // invalidation (e.g. the 30s backup scheduler replacing connection objects) even when the + // id/database values are unchanged — spuriously clearing the selection mid agent-run. + () => `${props.connection?.id ?? ""}::${props.tab?.database ?? ""}`, + () => { + activeTemplateIds.value = []; + }, +); + +function toggleTemplateId(id: string) { + if (activeTemplateIds.value.includes(id)) { + activeTemplateIds.value = activeTemplateIds.value.filter((tid) => tid !== id); + } else { + // Check total content limit + const tpl = promptTemplateStore.templates.find((t) => t.id === id); + if (tpl) { + const currentTotal = activeTemplates.value.reduce((sum, template) => sum + promptTemplateCharacterCount(template.content), 0); + if (currentTotal + promptTemplateCharacterCount(tpl.content) > ACTIVE_TEMPLATES_TOTAL_MAX) { + toast(t("ai.templateSelectorTooLong", { max: ACTIVE_TEMPLATES_TOTAL_MAX }), 4000); + return; + } + } + activeTemplateIds.value = [...activeTemplateIds.value, id]; + } +} + +function deselectAllTemplates() { + activeTemplateIds.value = []; +} + +const templateSelectorLabel = computed(() => { + if (!promptTemplateStore.isLoaded) return t("ai.templateSelectorLoading"); + const count = activeTemplates.value.length; + if (count === 0) return t("ai.templateSelectorNone"); + const name = activeTemplates.value[0].name; + if (count === 1) return name; + return `${name} +${count - 1}`; +}); const promptTextareaRef = ref(null); const shouldAutoScroll = ref(true); const userPausedAutoScroll = ref(false); @@ -1393,6 +1457,21 @@ async function send() { toast(t("ai.noConfig")); return; } + // Acquire the send guard before the first async operation so two rapid + // submissions cannot both pass the initial isGenerating check and then + // resume into concurrent agent runs. + isGenerating.value = true; + if (!(await promptTemplateStore.ensureLoaded())) { + isGenerating.value = false; + toast(t("ai.customInstructionsLoadFailed"), 5000); + return; + } + // Snapshot the selected custom prompts at send time so later async context loading + // cannot change the instructions for an already-submitted request. + const customPromptContext: CustomPromptContext = { + globalInstructions: promptTemplateStore.globalInstructions, + activeTemplates: [...activeTemplates.value], + }; const selectedTableMentions = [...selectedMentions.value]; const selectedSqlFiles = [...selectedSqlFileMentions.value]; @@ -1417,7 +1496,6 @@ async function send() { // Agent confirmation cannot grant autonomous writes while the active database is production. const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun && !productionContext.value.active; allowWriteSqlForNextRun = false; - isGenerating.value = true; messages.value.push({ role: "assistant", content: "" }); const assistantIdx = messages.value.length - 1; const sessionId = uuid(); @@ -1475,6 +1553,7 @@ async function send() { scrollToBottom(); }, sessionId, + customPromptContext, ); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); @@ -2120,6 +2199,44 @@ async function openExternalUrl(url: string) { @keydown="onPromptKeydown" />
+ + + + + + +
+
+ {{ t("ai.templateSelectorLoading") }} +
+
+ {{ t("ai.templateSelectorEmpty") }} +
+ +
+
+ +
+
+
{ void refreshSnippetTokenStatus(); }); -watch(activeSettingsTab, (tab) => { +watch(activeSettingsTab, async (tab) => { if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus(); if (tab === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus(); + if (tab === "ai") { + // Await completion so we don't snapshot an empty default when the store + // is still loading its first payload (init via App.vue is fire-and-forget). + await promptTemplateStore.ensureLoaded(); + editGlobalInstructions.value = promptTemplateStore.globalInstructions; + } if (tab === "about" && !appSupportInfo.value) void refreshAppSupportInfo(); if (tab === "appearance") { checkLayoutDescTruncation(); @@ -1977,6 +1987,18 @@ watch(activeSettingsTab, (tab) => { } }); +// If the store finishes loading while the AI tab is already open (e.g. a retry +// from another entry point succeeded after the tab-switch snapshot saw a failed +// load), backfill the textarea — but never clobber text the user already typed. +watch( + () => promptTemplateStore.isLoaded, + (loaded) => { + if (loaded && activeSettingsTab.value === "ai" && !editGlobalInstructions.value) { + editGlobalInstructions.value = promptTemplateStore.globalInstructions; + } + }, +); + onMounted(() => { void refreshWebDavPasswordStatus(); checkLayoutDescTruncation(); @@ -2026,6 +2048,116 @@ async function changePassword() { // ---------- AI Settings ---------- +// Global Custom Instructions +const editGlobalInstructions = ref(""); +const globalInstructionsSaving = ref(false); + +// Prompt Templates Management +const templateEditing = ref(null); +const templateFormOpen = ref(false); +const templateForm = ref<{ name: string; content: string }>({ name: "", content: "" }); +const templateFormIsNew = ref(true); +const templateSaving = ref(false); +const templateDeleteConfirm = ref(null); +const templateDeleteConfirmOpen = ref(false); + +watch(templateDeleteConfirm, (val) => { + templateDeleteConfirmOpen.value = val !== null; +}); +watch(templateDeleteConfirmOpen, (val) => { + if (!val) templateDeleteConfirm.value = null; +}); + +function openNewTemplate() { + templateForm.value = { name: "", content: "" }; + templateFormIsNew.value = true; + templateEditing.value = null; + templateFormOpen.value = true; +} + +function openEditTemplate(tpl: PromptTemplate) { + templateForm.value = { name: tpl.name, content: tpl.content }; + templateFormIsNew.value = false; + templateEditing.value = tpl; + templateFormOpen.value = true; +} + +function closeTemplateForm() { + templateForm.value = { name: "", content: "" }; + templateEditing.value = null; + templateFormOpen.value = false; +} + +function templateNameValid(): boolean { + return templateForm.value.name.trim().length > 0; +} + +function templateContentValid(): boolean { + return templateForm.value.content.trim().length > 0; +} + +function templateNameTooLong(): boolean { + return promptTemplateCharacterCount(templateForm.value.name) > PROMPT_TEMPLATE_NAME_MAX; +} + +function templateContentTooLong(): boolean { + return promptTemplateCharacterCount(templateForm.value.content) > PROMPT_TEMPLATE_CONTENT_MAX; +} + +function localNameDuplicate(): boolean { + const name = templateForm.value.name.trim(); + if (!name) return false; + const existingId = templateEditing.value?.id ?? ""; + return promptTemplateStore.templates.some((t) => t.id !== existingId && t.name.toLowerCase() === name.toLowerCase()); +} + +function templateSaveDisabled(): boolean { + return templateSaving.value || !templateNameValid() || !templateContentValid() || templateNameTooLong() || templateContentTooLong(); +} + +async function saveTemplateForm() { + if (templateSaveDisabled()) return; + templateSaving.value = true; + try { + const id = templateEditing.value?.id ?? uuid(); + await promptTemplateStore.save(id, templateForm.value.name.trim(), templateForm.value.content.trim()); + closeTemplateForm(); + toast(t("ai.promptTemplateSaved")); + } catch (e: any) { + toast(e?.message || String(e), 5000); + } finally { + templateSaving.value = false; + } +} + +async function confirmDeleteTemplate(tpl: PromptTemplate) { + try { + await promptTemplateStore.remove(tpl.id); + toast(t("ai.promptTemplateDeleted")); + } catch (e: any) { + toast(e?.message || String(e), 5000); + } finally { + templateDeleteConfirm.value = null; + } +} + +async function saveGlobalInstructions() { + if (promptTemplateCharacterCount(editGlobalInstructions.value) > GLOBAL_INSTRUCTIONS_MAX) return; + globalInstructionsSaving.value = true; + try { + await promptTemplateStore.saveGlobalInstructions(editGlobalInstructions.value); + toast(t("ai.globalInstructionsSaved")); + } catch (e: any) { + toast(e?.message || String(e), 5000); + } finally { + globalInstructionsSaving.value = false; + } +} + +function globalInstructionsTooLong(): boolean { + return promptTemplateCharacterCount(editGlobalInstructions.value) > GLOBAL_INSTRUCTIONS_MAX; +} + // AI Config Delete Confirmation const aiDeleteConfirmOpen = ref(false); const aiDeleteConfigId = ref(null); @@ -4568,6 +4700,92 @@ onUnmounted(cleanupPreviewEditor);
+ +
+ +
+

{{ t("ai.globalInstructions") }}

+

{{ t("ai.globalInstructionsDescription") }}

+
+ +
+ + {{ t("ai.globalInstructionsCharCount", { count: promptTemplateCharacterCount(editGlobalInstructions), max: GLOBAL_INSTRUCTIONS_MAX }) }} + + +
+
+ + +
+ +
+
+

{{ t("ai.promptTemplates") }}

+

{{ t("ai.promptTemplatesDescription") }}

+
+ +
+ + +
+
+
+
{{ tpl.name }}
+
{{ tpl.content.slice(0, 100) }}{{ tpl.content.length > 100 ? "..." : "" }}
+
+
+ + +
+
+
+ +
+

{{ t("ai.promptTemplateNoTemplates") }}

+
+ + +
+
+

{{ templateFormIsNew ? t("ai.promptTemplateNew") : t("ai.promptTemplateEdit") }}

+ +
+
+ + +
+

{{ t("ai.promptTemplateNameTooLong", { max: PROMPT_TEMPLATE_NAME_MAX }) }}

+

{{ t("ai.promptTemplateNameExists", { name: templateForm.name.trim() }) }}

+ + {{ promptTemplateCharacterCount(templateForm.name) }}/{{ PROMPT_TEMPLATE_NAME_MAX }} +
+
+
+ + +
+

{{ t("ai.promptTemplateTooLong", { max: PROMPT_TEMPLATE_CONTENT_MAX }) }}

+ + {{ promptTemplateCharacterCount(templateForm.content) }}/{{ PROMPT_TEMPLATE_CONTENT_MAX }} +
+
+
+ + +
+
+
+
@@ -5431,6 +5649,13 @@ onUnmounted(cleanupPreviewEditor); + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d2d3fb08b5..1f3039f91a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1575,6 +1575,40 @@ export default { editResend: "Resend", editCancel: "Cancel", cannotDeleteActiveConfig: "Cannot delete the currently active config. Switch to another config first.", + globalInstructions: "Global Custom Instructions", + globalInstructionsDescription: "Automatically applied to all AI conversations.", + globalInstructionsCharCount: "{count}/{max} characters", + globalInstructionsSaved: "Global instructions saved", + globalInstructionsSave: "Save", + globalInstructionsTooLong: "Global instructions too long (max {max} characters)", + customInstructionsLoadFailed: "Failed to load custom AI instructions. Please try again.", + promptTemplates: "Scenario Prompt Templates", + promptTemplatesDescription: "Select one or more templates in the AI assistant to inject scenario-specific conventions.", + promptTemplateNew: "New Template", + promptTemplateEdit: "Edit Template", + promptTemplateName: "Template Name", + promptTemplateNamePlaceholder: "e.g. Production DB rules", + promptTemplateContent: "Template Content", + promptTemplateContentPlaceholder: "Describe the convention or instruction...", + promptTemplateNameEmpty: "Template name cannot be empty", + promptTemplateNameExists: "Template name '{name}' already exists", + promptTemplateContentRequired: "Template content cannot be empty", + promptTemplateDeleteTitle: "Delete Template", + promptTemplateDeleteConfirm: "Delete template '{name}'? This cannot be undone.", + promptTemplateTooLong: "Template content too long (max {max} characters)", + promptTemplateNameTooLong: "Template name too long (max {max} characters)", + promptTemplateSaved: "Template saved", + promptTemplateDeleted: "Template deleted", + promptTemplateNoTemplates: "No templates yet. Create one to get started.", + promptTemplateManage: "Manage Templates", + promptTemplateSavedHint: "Changes are saved automatically.", + templateSelectorNone: "None", + templateSelectorButton: "Templates", + templateSelectorLabel: "Templates: {label}", + templateSelectorDeselectAll: "Deselect All", + templateSelectorLoading: "Loading...", + templateSelectorEmpty: "No templates. Click Manage to add.", + templateSelectorTooLong: "Selected templates exceed the total content limit ({max} characters). Deselect some to continue.", }, contextMenu: { openConnection: "Open Connection", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4022ec8f01..ed85c100c0 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1449,6 +1449,40 @@ export default withEnglishFallback({ proposalConfirmYes: "Sí, continuar", proposalConfirmNo: "No, gracias", proposalConfirmTitle: "Confirmación de acción requerida", + globalInstructions: "Instrucciones globales personalizadas", + globalInstructionsDescription: "Aplicadas automáticamente a todas las conversaciones con IA.", + globalInstructionsCharCount: "{count}/{max} caracteres", + globalInstructionsSaved: "Instrucciones globales guardadas", + globalInstructionsSave: "Guardar", + globalInstructionsTooLong: "Instrucciones globales demasiado largas (máx. {max} caracteres)", + customInstructionsLoadFailed: "Error al cargar las instrucciones personalizadas de IA. Inténtalo de nuevo.", + promptTemplates: "Plantillas de prompt por escenario", + promptTemplatesDescription: "Selecciona una o más plantillas en el asistente de IA para inyectar convenciones específicas del escenario.", + promptTemplateNew: "Nueva plantilla", + promptTemplateEdit: "Editar plantilla", + promptTemplateName: "Nombre de plantilla", + promptTemplateNamePlaceholder: "p. ej. Reglas de BD de producción", + promptTemplateContent: "Contenido de la plantilla", + promptTemplateContentPlaceholder: "Describe la convención o instrucción...", + promptTemplateNameEmpty: "El nombre de la plantilla no puede estar vacío", + promptTemplateNameExists: "El nombre de plantilla '{name}' ya existe", + promptTemplateContentRequired: "El contenido de la plantilla no puede estar vacío", + promptTemplateDeleteTitle: "Eliminar plantilla", + promptTemplateDeleteConfirm: "¿Eliminar plantilla '{name}'? Esta acción no se puede deshacer.", + promptTemplateTooLong: "Contenido de plantilla demasiado largo (máx. {max} caracteres)", + promptTemplateNameTooLong: "Nombre de plantilla demasiado largo (máx. {max} caracteres)", + promptTemplateSaved: "Plantilla guardada", + promptTemplateDeleted: "Plantilla eliminada", + promptTemplateNoTemplates: "Aún no hay plantillas. Crea una para empezar.", + promptTemplateManage: "Gestionar plantillas", + promptTemplateSavedHint: "Los cambios se guardan automáticamente.", + templateSelectorNone: "Ninguna", + templateSelectorButton: "Plantillas", + templateSelectorLabel: "Plantillas: {label}", + templateSelectorDeselectAll: "Deseleccionar todas", + templateSelectorLoading: "Cargando...", + templateSelectorEmpty: "Sin plantillas. Haz clic en Gestionar para añadir.", + templateSelectorTooLong: "Las plantillas seleccionadas superan el límite total de contenido ({max} caracteres). Deselecciona algunas para continuar.", agentSteps: { generated: "SQL generado", noSql: "No se encontró SQL", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3f047fd73c..77ad08ba7e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1414,6 +1414,40 @@ export default withEnglishFallback({ proposalConfirmYes: "Sì, procedi", proposalConfirmNo: "No, grazie", proposalConfirmTitle: "Conferma azione richiesta", + globalInstructions: "Istruzioni globali personalizzate", + globalInstructionsDescription: "Applicate automaticamente a tutte le conversazioni AI.", + globalInstructionsCharCount: "{count}/{max} caratteri", + globalInstructionsSaved: "Istruzioni globali salvate", + globalInstructionsSave: "Salva", + globalInstructionsTooLong: "Istruzioni globali troppo lunghe (massimo {max} caratteri)", + customInstructionsLoadFailed: "Impossibile caricare le istruzioni AI personalizzate. Riprova.", + promptTemplates: "Modelli di prompt per scenario", + promptTemplatesDescription: "Seleziona uno o più modelli nell'assistente AI per inserire convenzioni specifiche dello scenario.", + promptTemplateNew: "Nuovo modello", + promptTemplateEdit: "Modifica modello", + promptTemplateName: "Nome modello", + promptTemplateNamePlaceholder: "es. Regole DB di produzione", + promptTemplateContent: "Contenuto modello", + promptTemplateContentPlaceholder: "Descrivi la convenzione o l'istruzione...", + promptTemplateNameEmpty: "Il nome del modello non può essere vuoto", + promptTemplateNameExists: "Il nome modello '{name}' esiste già", + promptTemplateContentRequired: "Il contenuto del modello non può essere vuoto", + promptTemplateDeleteTitle: "Elimina modello", + promptTemplateDeleteConfirm: "Eliminare il modello '{name}'? Questa azione non può essere annullata.", + promptTemplateTooLong: "Contenuto modello troppo lungo (massimo {max} caratteri)", + promptTemplateNameTooLong: "Nome modello troppo lungo (massimo {max} caratteri)", + promptTemplateSaved: "Modello salvato", + promptTemplateDeleted: "Modello eliminato", + promptTemplateNoTemplates: "Nessun modello ancora. Creane uno per iniziare.", + promptTemplateManage: "Gestisci modelli", + promptTemplateSavedHint: "Le modifiche vengono salvate automaticamente.", + templateSelectorNone: "Nessuno", + templateSelectorButton: "Modelli", + templateSelectorLabel: "Modelli: {label}", + templateSelectorDeselectAll: "Deseleziona tutto", + templateSelectorLoading: "Caricamento...", + templateSelectorEmpty: "Nessun modello. Clicca Gestisci per aggiungerne.", + templateSelectorTooLong: "I modelli selezionati superano il limite totale di contenuto ({max} caratteri). Deseleziona alcuni per continuare.", agentSteps: { generated: "SQL generato", noSql: "Nessun SQL trovato", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6c5b6df362..b4254893e4 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1453,6 +1453,40 @@ export default withEnglishFallback({ proposalConfirmYes: "はい、続行", proposalConfirmNo: "いいえ", proposalConfirmTitle: "操作の確認", + globalInstructions: "グローバルカスタム指示", + globalInstructionsDescription: "すべてのAI会話に自動的に適用されます。", + globalInstructionsCharCount: "{count}/{max} 文字", + globalInstructionsSaved: "グローバル指示を保存しました", + globalInstructionsSave: "保存", + globalInstructionsTooLong: "グローバル指示が長すぎます(最大{max}文字)", + customInstructionsLoadFailed: "カスタムAI指示の読み込みに失敗しました。もう一度お試しください。", + promptTemplates: "シナリオプロンプトテンプレート", + promptTemplatesDescription: "AIアシスタントで1つ以上のテンプレートを選択し、シナリオ固有の規約を注入します。", + promptTemplateNew: "新規テンプレート", + promptTemplateEdit: "テンプレート編集", + promptTemplateName: "テンプレート名", + promptTemplateNamePlaceholder: "例:本番DBルール", + promptTemplateContent: "テンプレート内容", + promptTemplateContentPlaceholder: "規約や指示を記述...", + promptTemplateNameEmpty: "テンプレート名を入力してください", + promptTemplateNameExists: "テンプレート名 '{name}' は既に存在します", + promptTemplateContentRequired: "テンプレート内容を入力してください", + promptTemplateDeleteTitle: "テンプレート削除", + promptTemplateDeleteConfirm: "テンプレート '{name}' を削除しますか?この操作は元に戻せません。", + promptTemplateTooLong: "テンプレート内容が長すぎます(最大{max}文字)", + promptTemplateNameTooLong: "テンプレート名が長すぎます(最大{max}文字)", + promptTemplateSaved: "テンプレートを保存しました", + promptTemplateDeleted: "テンプレートを削除しました", + promptTemplateNoTemplates: "テンプレートがありません。新規作成してください。", + promptTemplateManage: "テンプレート管理", + promptTemplateSavedHint: "変更は自動的に保存されます。", + templateSelectorNone: "なし", + templateSelectorButton: "テンプレート", + templateSelectorLabel: "テンプレート: {label}", + templateSelectorDeselectAll: "すべて解除", + templateSelectorLoading: "読み込み中...", + templateSelectorEmpty: "テンプレートがありません。「管理」をクリックして追加してください。", + templateSelectorTooLong: "選択されたテンプレートの合計が上限({max}文字)を超えています。選択を解除してください。", agentSteps: { generated: "SQL生成完了", noSql: "SQLが見つかりません", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 44967b0027..25bbc01cea 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1451,6 +1451,40 @@ export default withEnglishFallback({ proposalConfirmYes: "Sim, continuar", proposalConfirmNo: "Não, obrigado", proposalConfirmTitle: "Confirmação de ação necessária", + globalInstructions: "Instruções globais personalizadas", + globalInstructionsDescription: "Aplicadas automaticamente a todas as conversas com IA.", + globalInstructionsCharCount: "{count}/{max} caracteres", + globalInstructionsSaved: "Instruções globais salvas", + globalInstructionsSave: "Salvar", + globalInstructionsTooLong: "Instruções globais muito longas (máx. {max} caracteres)", + customInstructionsLoadFailed: "Falha ao carregar instruções personalizadas de IA. Tente novamente.", + promptTemplates: "Modelos de prompt por cenário", + promptTemplatesDescription: "Selecione um ou mais modelos no assistente de IA para injetar convenções específicas do cenário.", + promptTemplateNew: "Novo modelo", + promptTemplateEdit: "Editar modelo", + promptTemplateName: "Nome do modelo", + promptTemplateNamePlaceholder: "ex. Regras de BD de produção", + promptTemplateContent: "Conteúdo do modelo", + promptTemplateContentPlaceholder: "Descreva a convenção ou instrução...", + promptTemplateNameEmpty: "O nome do modelo não pode estar vazio", + promptTemplateNameExists: "O nome de modelo '{name}' já existe", + promptTemplateContentRequired: "O conteúdo do modelo não pode estar vazio", + promptTemplateDeleteTitle: "Excluir modelo", + promptTemplateDeleteConfirm: "Excluir modelo '{name}'? Esta ação não pode ser desfeita.", + promptTemplateTooLong: "Conteúdo do modelo muito longo (máx. {max} caracteres)", + promptTemplateNameTooLong: "Nome do modelo muito longo (máx. {max} caracteres)", + promptTemplateSaved: "Modelo salvo", + promptTemplateDeleted: "Modelo excluído", + promptTemplateNoTemplates: "Nenhum modelo ainda. Crie um para começar.", + promptTemplateManage: "Gerenciar modelos", + promptTemplateSavedHint: "As alterações são salvas automaticamente.", + templateSelectorNone: "Nenhum", + templateSelectorButton: "Modelos", + templateSelectorLabel: "Modelos: {label}", + templateSelectorDeselectAll: "Desmarcar todos", + templateSelectorLoading: "Carregando...", + templateSelectorEmpty: "Nenhum modelo. Clique em Gerenciar para adicionar.", + templateSelectorTooLong: "Os modelos selecionados excedem o limite total de conteúdo ({max} caracteres). Desmarque alguns para continuar.", agentSteps: { generated: "SQL gerado", noSql: "Nenhum SQL encontrado", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 09f29e9283..837cc88f55 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1575,6 +1575,40 @@ export default withEnglishFallback({ editResend: "重新发送", editCancel: "取消", cannotDeleteActiveConfig: "无法删除当前活跃的配置,请先切换到其他配置", + globalInstructions: "全局自定义指令", + globalInstructionsDescription: "所有 AI 对话自动应用。", + globalInstructionsCharCount: "{count}/{max} 字符", + globalInstructionsSaved: "全局指令已保存", + globalInstructionsSave: "保存", + globalInstructionsTooLong: "全局指令过长(最多 {max} 字符)", + customInstructionsLoadFailed: "自定义 AI 规范加载失败,请稍后重试。", + promptTemplates: "场景 Prompt 模板", + promptTemplatesDescription: "在 AI 助手中选择一个或多个模板,注入场景化的约定规范。", + promptTemplateNew: "新建模板", + promptTemplateEdit: "编辑模板", + promptTemplateName: "模板名称", + promptTemplateNamePlaceholder: "例如:生产库规范", + promptTemplateContent: "模板内容", + promptTemplateContentPlaceholder: "描述约定或指令...", + promptTemplateNameEmpty: "模板名称不能为空", + promptTemplateNameExists: "模板名称“{name}”已存在", + promptTemplateContentRequired: "模板内容不能为空", + promptTemplateDeleteTitle: "删除模板", + promptTemplateDeleteConfirm: "确认删除模板“{name}”?此操作不可撤销。", + promptTemplateTooLong: "模板内容过长(最多 {max} 字符)", + promptTemplateNameTooLong: "模板名称过长(最多 {max} 字符)", + promptTemplateSaved: "模板已保存", + promptTemplateDeleted: "模板已删除", + promptTemplateNoTemplates: "暂无模板,请新建一个。", + promptTemplateManage: "管理模板", + promptTemplateSavedHint: "修改后自动保存。", + templateSelectorNone: "无", + templateSelectorButton: "模板", + templateSelectorLabel: "模板: {label}", + templateSelectorDeselectAll: "全不选", + templateSelectorLoading: "加载中...", + templateSelectorEmpty: "暂无模板,请点击管理添加。", + templateSelectorTooLong: "已选模板内容合计超过限制(最多 {max} 字符),请取消部分勾选。", }, contextMenu: { openConnection: "打开连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 970767d4f1..a6cad74f82 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1416,6 +1416,40 @@ export default withEnglishFallback({ proposalConfirmYes: "是,請繼續", proposalConfirmNo: "不用了", proposalConfirmTitle: "需要確認是否執行以下操作", + globalInstructions: "全域自訂指令", + globalInstructionsDescription: "自動套用至所有 AI 對話。", + globalInstructionsCharCount: "{count}/{max} 字元", + globalInstructionsSaved: "全域指令已儲存", + globalInstructionsSave: "儲存", + globalInstructionsTooLong: "全域指令過長(最多 {max} 字元)", + customInstructionsLoadFailed: "無法載入自訂 AI 規範,請稍後重試。", + promptTemplates: "場景 Prompt 範本", + promptTemplatesDescription: "在 AI 助手中選擇一或多個範本,注入場景化的約定規範。", + promptTemplateNew: "新增範本", + promptTemplateEdit: "編輯範本", + promptTemplateName: "範本名稱", + promptTemplateNamePlaceholder: "例如:正式環境資料庫規範", + promptTemplateContent: "範本內容", + promptTemplateContentPlaceholder: "描述約定或指令...", + promptTemplateNameEmpty: "範本名稱不可為空", + promptTemplateNameExists: "範本名稱「{name}」已存在", + promptTemplateContentRequired: "範本內容不可為空", + promptTemplateDeleteTitle: "刪除範本", + promptTemplateDeleteConfirm: "確認刪除範本「{name}」?此操作無法復原。", + promptTemplateTooLong: "範本內容過長(最多 {max} 字元)", + promptTemplateNameTooLong: "範本名稱過長(最多 {max} 字元)", + promptTemplateSaved: "範本已儲存", + promptTemplateDeleted: "範本已刪除", + promptTemplateNoTemplates: "尚無範本,請新增一個。", + promptTemplateManage: "管理範本", + promptTemplateSavedHint: "修改後自動儲存。", + templateSelectorNone: "無", + templateSelectorButton: "範本", + templateSelectorLabel: "範本: {label}", + templateSelectorDeselectAll: "取消全選", + templateSelectorLoading: "載入中...", + templateSelectorEmpty: "尚無範本,請點擊管理以新增。", + templateSelectorTooLong: "已選範本內容合計超過限制(最多 {max} 字元),請取消部分選取。", agentSteps: { generated: "已產生 SQL", noSql: "未找到 SQL", diff --git a/apps/desktop/src/lib/ai/ai.ts b/apps/desktop/src/lib/ai/ai.ts index a5b2f1c06c..454c073d20 100644 --- a/apps/desktop/src/lib/ai/ai.ts +++ b/apps/desktop/src/lib/ai/ai.ts @@ -1,6 +1,7 @@ import type { AiConfig } from "@/stores/settingsStore"; import { uuid } from "@/lib/common/utils"; import type { ColumnInfo, ConnectionConfig, DatabaseType, ForeignKeyInfo, IndexInfo, QueryResult, QueryTab } from "@/types/database"; +import type { PromptTemplate } from "@/types/promptTemplate"; import * as api from "@/lib/backend/api"; import { currentLocale, type Locale } from "@/i18n"; import { aiTableMentionKey, type AiTableMention } from "@/lib/ai/aiTableMentions"; @@ -96,9 +97,30 @@ export interface AiRequestInput { allowWriteSql?: boolean; } -function buildAgentRequest(input: AiRequestInput, history?: api.AiMessage[]): { messages: api.AiMessage[]; systemPrompt: string; taskContract: api.AiTaskContract; maxTokens: number } { +export interface CustomPromptContext { + globalInstructions?: string; + activeTemplates?: PromptTemplate[]; +} + +function buildCustomInstructionLines(custom: CustomPromptContext | undefined, isZh: boolean): string[] { + const global = custom?.globalInstructions?.trim() ?? ""; + const templates = (custom?.activeTemplates ?? []).filter((t) => t.content.trim()); + if (!global && templates.length === 0) return []; + + const parts: string[] = []; + if (global) parts.push(global); + parts.push(...templates.map((t) => `### ${t.name}\n${t.content}`)); + + return [ + isZh + ? `## 用户自定义规范(补充性)\n以下为用户定义的规范与模板;上方核心安全及方言规则优先级更高。\n\n${parts.join("\n\n")}` + : `## Custom Instructions (supplementary)\nThe following are user-defined conventions and templates. Core safety and dialect rules above take precedence.\n\n${parts.join("\n\n")}`, + ]; +} + +function buildAgentRequest(input: AiRequestInput, history?: api.AiMessage[], custom?: CustomPromptContext): { messages: api.AiMessage[]; systemPrompt: string; taskContract: api.AiTaskContract; maxTokens: number } { const isZh = isChineseLocale(currentLocale()); - const systemPrompt = buildSystemPrompt(input.action, input.context, input.mode); + const systemPrompt = buildSystemPrompt(input.action, input.context, input.mode, custom); const userPrompt = buildUserPrompt(input.action, input.context, input.instruction, isZh); const taskContract: api.AiTaskContract = { action: input.action, @@ -113,8 +135,8 @@ function buildAgentRequest(input: AiRequestInput, history?: api.AiMessage[]): { return { messages, systemPrompt, taskContract, maxTokens }; } -export async function runAiAction(input: AiRequestInput, history?: api.AiMessage[]): Promise { - const { messages, systemPrompt, taskContract, maxTokens } = buildAgentRequest(input, history); +export async function runAiAction(input: AiRequestInput, history?: api.AiMessage[], custom?: CustomPromptContext): Promise { + const { messages, systemPrompt, taskContract, maxTokens } = buildAgentRequest(input, history, custom); return api.aiComplete({ config: input.config, systemPrompt, @@ -124,8 +146,8 @@ export async function runAiAction(input: AiRequestInput, history?: api.AiMessage }); } -export async function runAiStream(input: AiRequestInput, history: api.AiMessage[] | undefined, onDelta: (delta: string) => void, sessionId?: string, onReasoningDelta?: (delta: string) => void): Promise { - const { messages, systemPrompt, taskContract, maxTokens } = buildAgentRequest(input, history); +export async function runAiStream(input: AiRequestInput, history: api.AiMessage[] | undefined, onDelta: (delta: string) => void, sessionId?: string, onReasoningDelta?: (delta: string) => void, custom?: CustomPromptContext): Promise { + const { messages, systemPrompt, taskContract, maxTokens } = buildAgentRequest(input, history, custom); const sid = sessionId || uuid(); await api.aiStream( @@ -146,8 +168,8 @@ export async function runAiStream(input: AiRequestInput, history: api.AiMessage[ ); } -export async function runAgentStream(input: AiRequestInput, history: api.AiMessage[] | undefined, onEvent: (event: AgentEvent) => void, sessionId?: string): Promise { - const { messages, systemPrompt, taskContract, maxTokens } = buildAgentRequest(input, history); +export async function runAgentStream(input: AiRequestInput, history: api.AiMessage[] | undefined, onEvent: (event: AgentEvent) => void, sessionId?: string, custom?: CustomPromptContext): Promise { + const { messages, systemPrompt, taskContract, maxTokens } = buildAgentRequest(input, history, custom); const sid = sessionId || uuid(); return api.aiAgentStream( @@ -199,9 +221,9 @@ export function extractSql(text: string): string { return text.trim(); } -export function buildSystemPrompt(action: AiAction, context: AiContext, mode: AiAssistantMode = "ask"): string { +export function buildSystemPrompt(action: AiAction, context: AiContext, mode: AiAssistantMode = "ask", custom?: CustomPromptContext): string { if (isVectorDbType(context.databaseType)) { - return buildVectorSystemPrompt(context, mode); + return buildVectorSystemPrompt(context, mode, custom); } const schema = formatSchema(context); const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : ""; @@ -211,7 +233,7 @@ export function buildSystemPrompt(action: AiAction, context: AiContext, mode: Ai const isZh = isChineseLocale(currentLocale()); - const lines: string[] = [...buildBasePromptLines(isZh), ...buildModePromptLines(mode, isZh), ...buildActionPromptLines(action, isZh)]; + const lines: string[] = [...buildBasePromptLines(isZh), ...buildModePromptLines(mode, isZh), ...buildActionPromptLines(action, isZh), ...buildCustomInstructionLines(custom, isZh)]; if (schemaScope === "focused_table") { lines.push( @@ -271,7 +293,7 @@ function buildBasePromptLines(isZh: boolean): string[] { ]; } -function buildVectorSystemPrompt(context: AiContext, mode: AiAssistantMode): string { +function buildVectorSystemPrompt(context: AiContext, mode: AiAssistantMode, custom?: CustomPromptContext): string { const isZh = isChineseLocale(currentLocale()); const schema = formatSchema(context); const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : ""; @@ -281,6 +303,7 @@ function buildVectorSystemPrompt(context: AiContext, mode: AiAssistantMode): str isZh ? `你是 DBX 内置的向量数据库助手。当前连接的是 ${dbLabel(context.databaseType)} 数据库。用中文回复。` : `You are DBX's vector database assistant. Connected to ${dbLabel(context.databaseType)}. Reply in English.`, isZh ? "数据存储在集合(collections)中,每条记录包含唯一标识及可选的元数据负载(payload/metadata)。" : "Data is stored in collections. Each record has a unique identifier and optional metadata payload.", ...buildVectorModePromptLines(context, mode, isZh), + ...buildCustomInstructionLines(custom, isZh), "", `Database type: ${context.databaseType}`, `Connection: ${context.connectionName}`, diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 59679293c9..f5f0831f27 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -287,6 +287,13 @@ export const saveAiConversation = forward("saveAiConversation"); export const loadAiConversations = forward("loadAiConversations"); export const deleteAiConversation = forward("deleteAiConversation"); +// Prompt Templates +export const loadPromptTemplates = forward("loadPromptTemplates"); +export const savePromptTemplate = forward("savePromptTemplate"); +export const deletePromptTemplate = forward("deletePromptTemplate"); +export const getAiGlobalCustomInstructions = forward("getAiGlobalCustomInstructions"); +export const setAiGlobalCustomInstructions = forward("setAiGlobalCustomInstructions"); + // System export const listSystemFonts = forward("listSystemFonts"); export const listSshConfigHosts = forward("listSshConfigHosts"); @@ -527,6 +534,7 @@ export type { AiModelInfo, AiChatMessage, AiConversation, + PromptTemplate, AgentDriverInfo, DriverStoreUsage, DriverStoreUsageItem, diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index e105f4ae93..9631ac7e7f 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -109,6 +109,7 @@ import type { DroppedFilePreviewSqlOptions, MongoGridFsFileInfo, AppSupportInfo, + PromptTemplate, } from "@/lib/backend/tauri"; import type { QueryEditability } from "@/lib/sql/sqlAnalysis"; import { isTerminalTransferProgress } from "@/lib/backend/transferProgress"; @@ -203,6 +204,16 @@ async function del(url: string): Promise { return res.json(); } +async function put(url: string, body: unknown): Promise { + const res = await fetch(apiUrl(url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + function qs(params: Record): string { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { @@ -1403,6 +1414,31 @@ export async function deleteAiConversation(id: string): Promise { return del(`/api/ai/conversation/${id}`); } +// --------------------------------------------------------------------------- +// Prompt Templates +// --------------------------------------------------------------------------- + +export async function loadPromptTemplates(): Promise { + return get("/api/prompt-templates"); +} + +export async function savePromptTemplate(id: string, name: string, content: string): Promise { + return post("/api/prompt-templates", { id, name, content }); +} + +export async function deletePromptTemplate(id: string): Promise { + return del(`/api/prompt-templates/${encodeURIComponent(id)}`); +} + +export async function getAiGlobalCustomInstructions(): Promise { + const result = await get<{ content: string }>("/api/prompt-templates/global-instructions"); + return result.content ?? ""; +} + +export async function setAiGlobalCustomInstructions(content: string): Promise { + return put("/api/prompt-templates/global-instructions", { content }); +} + // --------------------------------------------------------------------------- // SQL File Execution // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index d31ec8d7bd..e919f7b645 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -677,6 +677,36 @@ export async function deleteAiConversation(id: string): Promise { return invoke("delete_ai_conversation", { id }); } +// --- Prompt Templates --- + +export interface PromptTemplate { + id: string; + name: string; + content: string; + createdAt: string; + updatedAt: string; +} + +export async function loadPromptTemplates(): Promise { + return invoke("load_prompt_templates"); +} + +export async function savePromptTemplate(id: string, name: string, content: string): Promise { + return invoke("save_prompt_template", { id, name, content }); +} + +export async function deletePromptTemplate(id: string): Promise { + return invoke("delete_prompt_template", { id }); +} + +export async function getAiGlobalCustomInstructions(): Promise { + return invoke("get_ai_global_custom_instructions"); +} + +export async function setAiGlobalCustomInstructions(content: string): Promise { + return invoke("set_ai_global_custom_instructions", { content }); +} + export async function testConnection(config: ConnectionConfig): Promise { return invoke("test_connection", { config }); } diff --git a/apps/desktop/src/stores/promptTemplateStore.ts b/apps/desktop/src/stores/promptTemplateStore.ts new file mode 100644 index 0000000000..58ad4317b1 --- /dev/null +++ b/apps/desktop/src/stores/promptTemplateStore.ts @@ -0,0 +1,71 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import * as api from "@/lib/backend/api"; +import type { PromptTemplate } from "@/types/promptTemplate"; + +export const usePromptTemplateStore = defineStore("promptTemplate", () => { + const templates = ref([]); + const globalInstructions = ref(""); + const isLoaded = ref(false); + const isLoading = ref(false); + let loadPromise: Promise | null = null; + + async function init(): Promise { + if (isLoaded.value) return true; + if (loadPromise) return loadPromise; + isLoading.value = true; + loadPromise = (async () => { + try { + const [tpls, gi] = await Promise.all([api.loadPromptTemplates(), api.getAiGlobalCustomInstructions()]); + templates.value = tpls; + globalInstructions.value = gi; + isLoaded.value = true; + return true; + } catch { + // Keep failed initialization retryable, but do not let callers send + // AI requests with an incomplete global-instructions context. + return false; + } finally { + isLoading.value = false; + loadPromise = null; + } + })(); + return loadPromise; + } + + /** Return whether prompt data is ready; failed initialization remains retryable. */ + async function ensureLoaded(): Promise { + return init(); + } + + async function save(id: string, name: string, content: string): Promise { + const saved = await api.savePromptTemplate(id, name, content); + const idx = templates.value.findIndex((t) => t.id === id); + if (idx >= 0) { + templates.value[idx] = saved; + } else { + templates.value.push(saved); + } + // Maintain stable sort order: created_at, then id + templates.value = [...templates.value].sort(sortTemplates); + return saved; + } + + async function remove(id: string): Promise { + await api.deletePromptTemplate(id); + templates.value = templates.value.filter((t) => t.id !== id); + } + + async function saveGlobalInstructions(content: string): Promise { + const trimmed = content.trim(); + await api.setAiGlobalCustomInstructions(trimmed); + globalInstructions.value = trimmed; + } + + return { templates, globalInstructions, isLoaded, isLoading, init, ensureLoaded, save, remove, saveGlobalInstructions }; +}); + +function sortTemplates(a: PromptTemplate, b: PromptTemplate): number { + if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1; + return a.id < b.id ? -1 : 1; +} diff --git a/apps/desktop/src/types/promptTemplate.ts b/apps/desktop/src/types/promptTemplate.ts new file mode 100644 index 0000000000..86eb7d4e73 --- /dev/null +++ b/apps/desktop/src/types/promptTemplate.ts @@ -0,0 +1,17 @@ +export interface PromptTemplate { + id: string; + name: string; + content: string; + createdAt: string; + updatedAt: string; +} + +export const PROMPT_TEMPLATE_NAME_MAX = 50; +export const PROMPT_TEMPLATE_CONTENT_MAX = 8000; +export const GLOBAL_INSTRUCTIONS_MAX = 8000; +export const ACTIVE_TEMPLATES_TOTAL_MAX = 16000; + +/** Matches Rust str::chars().count() for frontend prompt validation. */ +export function promptTemplateCharacterCount(value: string): number { + return Array.from(value).length; +} diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 998a0a324e..56b7daa952 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -41,6 +41,7 @@ pub mod path_utils; pub mod plugins; pub mod process; pub mod production_safety; +pub mod prompt_template; pub mod query; pub mod query_cancel; pub mod query_execution_sql; diff --git a/crates/dbx-core/src/prompt_template.rs b/crates/dbx-core/src/prompt_template.rs new file mode 100644 index 0000000000..209469af21 --- /dev/null +++ b/crates/dbx-core/src/prompt_template.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PromptTemplate { + pub id: String, + pub name: String, + pub content: String, + pub created_at: String, + pub updated_at: String, +} diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index d914f286a0..b3d59f1791 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -19,6 +19,7 @@ use crate::history::{ HistorySearchRequest, HistorySearchResult, MAX_HISTORY, }; use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType, TransportLayerConfig}; +use crate::prompt_template::PromptTemplate; use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary}; const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels."; @@ -28,6 +29,7 @@ const APP_STATE_EDITOR_SETTINGS_KEY: &str = "editor_settings"; const APP_STATE_OPEN_TABS_KEY: &str = "open_tabs"; const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positions"; const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy"; +const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions"; const USER_DATA_TABLES: &[&str] = &[ "connections", "connection_secrets", @@ -372,6 +374,13 @@ const SCHEMA_STATEMENTS: &[&str] = &[ config_json TEXT NOT NULL, is_default INTEGER NOT NULL DEFAULT 0 )", + "CREATE TABLE IF NOT EXISTS prompt_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' + )", ]; impl Storage { @@ -1630,6 +1639,24 @@ impl Storage { self.load_app_state_value(APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY).await } + pub async fn save_ai_global_custom_instructions(&self, content: &str) -> Result<(), String> { + let trimmed = content.trim(); + if trimmed.chars().count() > 8000 { + return Err("global instructions too long (max 8000 chars)".to_string()); + } + self.save_app_state_value(APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY, &serde_json::Value::String(trimmed.to_string())) + .await + } + + pub async fn load_ai_global_custom_instructions(&self) -> Result { + let value = self.load_app_state_value(APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY).await?; + Ok(match value { + Some(serde_json::Value::String(s)) => s, + None | Some(serde_json::Value::Null) => String::new(), + other => other.map(|v| v.to_string()).unwrap_or_default(), + }) + } + pub async fn load_or_create_local_device_secret(&self) -> Result { let mut settings = self.load_app_settings_json().await?; if let Some(secret) = settings.get("local_device_secret").and_then(|value| value.as_str()) { @@ -1772,6 +1799,108 @@ impl Storage { }) .await } + + // Prompt Templates + + pub async fn load_prompt_templates(&self) -> Result, String> { + self.with_conn(|conn| { + let mut stmt = conn + .prepare( + "SELECT id, name, content, created_at, updated_at \ + FROM prompt_templates ORDER BY created_at, id", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| { + Ok(PromptTemplate { + id: row.get(0)?, + name: row.get(1)?, + content: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + }) + .await + } + + pub async fn save_prompt_template(&self, id: &str, name: &str, content: &str) -> Result { + let id = id.to_string(); + let name = name.trim().to_string(); + let content = content.to_string(); + + // Validation + if name.is_empty() { + return Err("template name cannot be empty".to_string()); + } + if name.chars().count() > 50 { + return Err("template name too long (max 50 chars)".to_string()); + } + if content.chars().count() > 8000 { + return Err("template content too long (max 8000 chars)".to_string()); + } + + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + self.with_conn(move |conn| { + // Case-insensitive duplicate name check (Unicode-aware). + // SQLite LOWER() is ASCII-only, so we compare in Rust where + // str::to_lowercase() handles full Unicode case folding. + let name_lower = name.to_lowercase(); + let mut stmt = conn + .prepare("SELECT name FROM prompt_templates WHERE id != ?1") + .map_err(|e| e.to_string())?; + let duplicate = stmt + .query_map(params![id], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .any(|existing| existing.to_lowercase() == name_lower); + if duplicate { + return Err("duplicate template name".to_string()); + } + + // Check if row exists to decide INSERT vs UPDATE + let existing_created_at: Option = conn + .query_row("SELECT created_at FROM prompt_templates WHERE id = ?1", params![id], |row| row.get(0)) + .optional() + .map_err(|e| e.to_string())?; + + if let Some(created_at) = existing_created_at { + // UPDATE — preserve created_at + conn.execute( + "UPDATE prompt_templates SET name = ?1, content = ?2, updated_at = ?3 WHERE id = ?4", + params![name, content, now, id], + ) + .map_err(|e| e.to_string())?; + Ok(PromptTemplate { id, name, content, created_at, updated_at: now }) + } else { + // INSERT + conn.execute( + "INSERT INTO prompt_templates (id, name, content, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, name, content, now, now], + ) + .map_err(|e| e.to_string())?; + Ok(PromptTemplate { id, name, content, created_at: now.clone(), updated_at: now }) + } + }) + .await + } + + pub async fn delete_prompt_template(&self, id: &str) -> Result<(), String> { + let id = id.to_string(); + self.with_conn(move |conn| { + let rows = + conn.execute("DELETE FROM prompt_templates WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + if rows == 0 { + Err("template not found".to_string()) + } else { + Ok(()) + } + }) + .await + } } // Connections @@ -4701,4 +4830,280 @@ mod tests { std::fs::remove_file(&db).ok(); } + + // --- Prompt Templates --- + + #[tokio::test] + async fn prompt_template_save_new_creates_timestamps() { + let db = temp_db_path("pt-save-new"); + let storage = Storage::open(&db).await.unwrap(); + + let result = storage.save_prompt_template("t1", "Production Rules", "SELECT 1").await.unwrap(); + + assert_eq!(result.id, "t1"); + assert_eq!(result.name, "Production Rules"); + assert_eq!(result.content, "SELECT 1"); + assert!(!result.created_at.is_empty()); + assert_eq!(result.created_at, result.updated_at); + + // Verify it's persisted in load + let templates = storage.load_prompt_templates().await.unwrap(); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].id, "t1"); + assert_eq!(templates[0].created_at, result.created_at); + assert_eq!(templates[0].updated_at, result.updated_at); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_save_update_preserves_created_at() { + let db = temp_db_path("pt-save-update"); + let storage = Storage::open(&db).await.unwrap(); + + let first = storage.save_prompt_template("t1", "Original Name", "Original content").await.unwrap(); + // Ensure some time passes so updated_at changes + std::thread::sleep(std::time::Duration::from_millis(1100)); + + let second = storage.save_prompt_template("t1", "Updated Name", "Updated content").await.unwrap(); + + assert_eq!(second.id, "t1"); + assert_eq!(second.name, "Updated Name"); + assert_eq!(second.content, "Updated content"); + assert_eq!(second.created_at, first.created_at, "created_at must be preserved on update"); + assert_ne!(second.updated_at, first.updated_at, "updated_at must change on update"); + + // Verify only one row exists + let templates = storage.load_prompt_templates().await.unwrap(); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].name, "Updated Name"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_save_rejects_blank_name() { + let db = temp_db_path("pt-name-blank"); + let storage = Storage::open(&db).await.unwrap(); + + let err = storage.save_prompt_template("t1", "", "content").await.unwrap_err(); + assert!(err.contains("cannot be empty"), "expected 'cannot be empty', got: {err}"); + + let err = storage.save_prompt_template("t1", " ", "content").await.unwrap_err(); + assert!(err.contains("cannot be empty"), "expected 'cannot be empty', got: {err}"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_save_rejects_long_name() { + let db = temp_db_path("pt-name-long"); + let storage = Storage::open(&db).await.unwrap(); + + let long_name = "a".repeat(51); + let err = storage.save_prompt_template("t1", &long_name, "content").await.unwrap_err(); + assert!(err.contains("too long") && err.contains("50"), "expected too long (max 50), got: {err}"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_accepts_multi_byte_characters_within_char_limit() { + let db = temp_db_path("pt-multibyte-name"); + let storage = Storage::open(&db).await.unwrap(); + + // 25 Chinese characters = 75 bytes but only 25 chars — should be allowed under 50 char limit + let name25 = "数".repeat(25); // 25 chars, 75 bytes + assert_eq!(name25.chars().count(), 25); + assert!(name25.len() > 50); // byte length exceeds 50 + + let result = storage.save_prompt_template("t1", &name25, "content").await.unwrap(); + assert_eq!(result.name, name25); + + // 51 Chinese characters = 153 bytes — should be rejected (51 chars > 50) + let name51 = "数".repeat(51); + assert_eq!(name51.chars().count(), 51); + let err = storage.save_prompt_template("t2", &name51, "content").await.unwrap_err(); + assert!(err.contains("too long") && err.contains("50"), "expected too long (max 50), got: {err}"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_save_rejects_long_content() { + let db = temp_db_path("pt-content-long"); + let storage = Storage::open(&db).await.unwrap(); + + let long_content = "a".repeat(8001); + let err = storage.save_prompt_template("t1", "Valid Name", &long_content).await.unwrap_err(); + assert!(err.contains("too long") && err.contains("8000"), "expected too long (max 8000), got: {err}"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_save_rejects_duplicate_name_case_insensitive() { + let db = temp_db_path("pt-dup-name"); + let storage = Storage::open(&db).await.unwrap(); + + storage.save_prompt_template("t1", "Production Rules", "content 1").await.unwrap(); + + // Same name, different id → should fail + let err = storage.save_prompt_template("t2", "production rules", "content 2").await.unwrap_err(); + assert!(err.contains("duplicate"), "expected 'duplicate', got: {err}"); + + // Same name, same id → should update (not fail) + let update = storage.save_prompt_template("t1", "Production Rules", "updated").await.unwrap(); + assert_eq!(update.id, "t1"); + assert_eq!(update.content, "updated"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_save_rejects_duplicate_name_unicode_case_folding() { + let db = temp_db_path("pt-dup-unicode"); + let storage = Storage::open(&db).await.unwrap(); + + // SQLite LOWER() is ASCII-only (U+00C4 'Ä' → no change), but Rust + // str::to_lowercase() does full Unicode case folding (Ä → ä). + // Both directions must detect the duplicate. + storage.save_prompt_template("t1", "Ä规则", "content-upper").await.unwrap(); + let err = storage.save_prompt_template("t2", "ä规则", "content-lower").await.unwrap_err(); + assert!(err.contains("duplicate"), "expected 'duplicate', got: {err}"); + + // Reverse: lower-case first, upper-case second. + let db = temp_db_path("pt-dup-unicode-2"); + let storage = Storage::open(&db).await.unwrap(); + storage.save_prompt_template("t1", "ä规则", "content-lower").await.unwrap(); + let err = storage.save_prompt_template("t2", "Ä规则", "content-upper").await.unwrap_err(); + assert!(err.contains("duplicate"), "expected 'duplicate', got: {err}"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_load_order_is_stable() { + let db = temp_db_path("pt-load-order"); + let storage = Storage::open(&db).await.unwrap(); + + // Insert in reverse order of created_at by sleeping between inserts + storage.save_prompt_template("a", "Template A", "a").await.unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + storage.save_prompt_template("b", "Template B", "b").await.unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + storage.save_prompt_template("c", "Template C", "c").await.unwrap(); + + let templates = storage.load_prompt_templates().await.unwrap(); + assert_eq!(templates.len(), 3); + // Order should be by created_at ascending: A first, C last + assert_eq!(templates[0].id, "a"); + assert_eq!(templates[1].id, "b"); + assert_eq!(templates[2].id, "c"); + + // Insert with same created_at — tie-break by id + // We insert d right after c without delay + storage.save_prompt_template("d", "Template D", "d").await.unwrap(); + + let templates = storage.load_prompt_templates().await.unwrap(); + assert_eq!(templates.len(), 4); + assert_eq!(templates[3].id, "d"); + + // Second load should give same order + let templates2 = storage.load_prompt_templates().await.unwrap(); + assert_eq!(templates, templates2); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_delete_unknown_id_errors() { + let db = temp_db_path("pt-delete-unknown"); + let storage = Storage::open(&db).await.unwrap(); + + let err = storage.delete_prompt_template("nonexistent").await.unwrap_err(); + assert!(err.contains("not found"), "expected 'not found', got: {err}"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn prompt_template_delete_existing_removes() { + let db = temp_db_path("pt-delete-existing"); + let storage = Storage::open(&db).await.unwrap(); + + storage.save_prompt_template("t1", "Template", "content").await.unwrap(); + assert_eq!(storage.load_prompt_templates().await.unwrap().len(), 1); + + storage.delete_prompt_template("t1").await.unwrap(); + assert!(storage.load_prompt_templates().await.unwrap().is_empty()); + + std::fs::remove_file(&db).ok(); + } + + // --- Global Custom Instructions --- + + #[tokio::test] + async fn global_instructions_set_get_roundtrip() { + let db = temp_db_path("gi-roundtrip"); + let storage = Storage::open(&db).await.unwrap(); + + let instructions = "Amounts are in cents. Always filter by date range."; + storage.save_ai_global_custom_instructions(instructions).await.unwrap(); + + let loaded = storage.load_ai_global_custom_instructions().await.unwrap(); + assert_eq!(loaded, instructions); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn global_instructions_defaults_to_empty() { + let db = temp_db_path("gi-default"); + let storage = Storage::open(&db).await.unwrap(); + + let loaded = storage.load_ai_global_custom_instructions().await.unwrap(); + assert_eq!(loaded, ""); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn global_instructions_rejects_too_long() { + let db = temp_db_path("gi-too-long"); + let storage = Storage::open(&db).await.unwrap(); + + let long = "a".repeat(8001); + let err = storage.save_ai_global_custom_instructions(&long).await.unwrap_err(); + assert!(err.contains("too long") && err.contains("8000"), "expected too long (max 8000), got: {err}"); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn global_instructions_empty_string_clears() { + let db = temp_db_path("gi-clear"); + let storage = Storage::open(&db).await.unwrap(); + + storage.save_ai_global_custom_instructions("Some instructions").await.unwrap(); + assert_eq!(storage.load_ai_global_custom_instructions().await.unwrap(), "Some instructions"); + + // Empty string (trimmed) is allowed — equivalent to clear + storage.save_ai_global_custom_instructions("").await.unwrap(); + assert_eq!(storage.load_ai_global_custom_instructions().await.unwrap(), ""); + + std::fs::remove_file(&db).ok(); + } + + #[tokio::test] + async fn global_instructions_whitespace_only_trims() { + let db = temp_db_path("gi-whitespace"); + let storage = Storage::open(&db).await.unwrap(); + + storage.save_ai_global_custom_instructions(" \n \t ").await.unwrap(); + let loaded = storage.load_ai_global_custom_instructions().await.unwrap(); + assert_eq!(loaded, ""); + + std::fs::remove_file(&db).ok(); + } } diff --git a/crates/dbx-web/src/error.rs b/crates/dbx-web/src/error.rs index d123e22fb8..01d5ea070d 100644 --- a/crates/dbx-web/src/error.rs +++ b/crates/dbx-web/src/error.rs @@ -1,32 +1,39 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -pub struct AppError(pub String); +pub struct AppError { + pub message: String, + pub status: StatusCode, +} impl AppError { pub fn internal(msg: impl Into) -> Self { - AppError(msg.into()) + AppError { message: msg.into(), status: StatusCode::INTERNAL_SERVER_ERROR } } pub fn bad_request(msg: impl Into) -> Self { - AppError(msg.into()) + AppError { message: msg.into(), status: StatusCode::BAD_REQUEST } + } + + pub fn not_found(msg: impl Into) -> Self { + AppError { message: msg.into(), status: StatusCode::NOT_FOUND } } } impl IntoResponse for AppError { fn into_response(self) -> Response { - (StatusCode::INTERNAL_SERVER_ERROR, self.0).into_response() + (self.status, self.message).into_response() } } impl From for AppError { fn from(s: String) -> Self { - AppError(s) + AppError { message: s, status: StatusCode::INTERNAL_SERVER_ERROR } } } impl From<&str> for AppError { fn from(s: &str) -> Self { - AppError(s.to_string()) + AppError { message: s.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR } } } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 813d027de9..1a928f10ba 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -517,6 +517,16 @@ async fn main() { .route("/ai/cancel-stream", post(routes::ai::ai_cancel_stream)) .route("/ai/test-connection", post(routes::ai::ai_test_connection)) .route("/ai/models", post(routes::ai::ai_list_models)) + // Prompt templates + .route( + "/prompt-templates", + get(routes::prompt_template::load_prompt_templates).post(routes::prompt_template::save_prompt_template), + ) + .route("/prompt-templates/{id}", delete(routes::prompt_template::delete_prompt_template)) + .route( + "/prompt-templates/global-instructions", + get(routes::prompt_template::get_global_instructions).put(routes::prompt_template::set_global_instructions), + ) // Transfer .route("/transfer/start", post(routes::transfer::start_transfer)) .route("/transfer/ownership-preview", post(routes::transfer::preview_transfer_ownership)) diff --git a/crates/dbx-web/src/routes/agents.rs b/crates/dbx-web/src/routes/agents.rs index d18c96e8ac..4a3c0e32b6 100644 --- a/crates/dbx-web/src/routes/agents.rs +++ b/crates/dbx-web/src/routes/agents.rs @@ -64,7 +64,7 @@ pub async fn get_driver_store_usage(State(state): State>) -> Resul pub async fn clear_driver_download_cache( State(state): State>, ) -> Result, AppError> { - clear_agent_download_cache(&state.app.agent_manager).map_err(AppError)?; + clear_agent_download_cache(&state.app.agent_manager).map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -84,7 +84,7 @@ pub async fn stop_driver_runtime( State(state): State>, Json(req): Json, ) -> Result, AppError> { - dbx_core::driver_runtime::stop_driver_runtime(&state.app, &req.runtime_id).await.map_err(AppError)?; + dbx_core::driver_runtime::stop_driver_runtime(&state.app, &req.runtime_id).await.map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -92,7 +92,7 @@ pub async fn restart_driver_runtime( State(state): State>, Json(req): Json, ) -> Result, AppError> { - dbx_core::driver_runtime::restart_driver_runtime(&state.app, &req.runtime_id).await.map_err(AppError)?; + dbx_core::driver_runtime::restart_driver_runtime(&state.app, &req.runtime_id).await.map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -100,32 +100,32 @@ pub async fn install_agent( State(state): State>, Json(req): Json, ) -> Result, AppError> { - ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&req.db_type)).await.map_err(AppError)?; + ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&req.db_type)).await.map_err(AppError::from)?; let tx = progress_sender(&state, "global").await; install_agent_driver(&state.app.agent_manager, &req.db_type, |event| send_progress_event(&tx, event)) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } pub async fn upgrade_all_agents(State(state): State>) -> Result, AppError> { - let registry = fetch_registry().await.map_err(AppError)?; + let registry = fetch_registry().await.map_err(AppError::from)?; let agents = build_agent_list(&state.app.agent_manager, Some(®istry)); let updatable: Vec = agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect(); - ensure_no_agent_update_blockers(&state.app, &updatable).await.map_err(AppError)?; + ensure_no_agent_update_blockers(&state.app, &updatable).await.map_err(AppError::from)?; let tx = progress_sender(&state, "global").await; let result = upgrade_all_agent_drivers(&state.app.agent_manager, |event| send_progress_event(&tx, event)) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|err| AppError(err.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|err| AppError::from(err.to_string()))?)) } pub async fn uninstall_agent( State(state): State>, Json(req): Json, ) -> Result, AppError> { - uninstall_agent_driver(&state.app.agent_manager, &req.db_type).await.map_err(AppError)?; + uninstall_agent_driver(&state.app.agent_manager, &req.db_type).await.map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -143,7 +143,7 @@ pub async fn set_agent_java_runtime_config( let mut config = req.config; if config.mode == JavaRuntimeMode::Custom || config.mode == JavaRuntimeMode::System { let candidate_state = AgentState { java_runtime: config.clone(), ..am.load_state() }; - let resolved = am.resolve_java_runtime(&candidate_state, DEFAULT_JRE_KEY).map_err(AppError)?; + let resolved = am.resolve_java_runtime(&candidate_state, DEFAULT_JRE_KEY).map_err(AppError::from)?; if config.mode == JavaRuntimeMode::Custom { config.custom_java_path = Some(resolved.to_string_lossy().to_string()); } @@ -154,7 +154,7 @@ pub async fn set_agent_java_runtime_config( let mut local_state = am.load_state(); local_state.java_runtime = config.clone(); - am.save_state(&local_state).map_err(AppError)?; + am.save_state(&local_state).map_err(AppError::from)?; am.stop_daemons().await; Ok(Json(config)) } @@ -169,29 +169,29 @@ pub async fn import_agents_from_zip( mut multipart: Multipart, ) -> Result, AppError> { let tmp_dir = state.data_dir.join("tmp"); - std::fs::create_dir_all(&tmp_dir).map_err(|err| AppError(err.to_string()))?; + std::fs::create_dir_all(&tmp_dir).map_err(|err| AppError::from(err.to_string()))?; - if let Some(field) = multipart.next_field().await.map_err(|err| AppError(err.to_string()))? { + if let Some(field) = multipart.next_field().await.map_err(|err| AppError::from(err.to_string()))? { let file_name = field.file_name().unwrap_or("offline-drivers.zip").to_string(); if !file_name.to_ascii_lowercase().ends_with(".zip") { - return Err(AppError("Offline driver package must be a .zip file".to_string())); + return Err(AppError::from("Offline driver package must be a .zip file".to_string())); } let zip_path = tmp_dir.join(format!("agent-offline-{}.zip", uuid::Uuid::new_v4())); let tx = progress_sender(&state, "global").await; let result = async { - let mut upload = tokio::fs::File::create(&zip_path).await.map_err(|err| AppError(err.to_string()))?; + let mut upload = tokio::fs::File::create(&zip_path).await.map_err(|err| AppError::from(err.to_string()))?; let mut field = field; - while let Some(chunk) = field.chunk().await.map_err(|err| AppError(err.to_string()))? { - upload.write_all(&chunk).await.map_err(|err| AppError(err.to_string()))?; + while let Some(chunk) = field.chunk().await.map_err(|err| AppError::from(err.to_string()))? { + upload.write_all(&chunk).await.map_err(|err| AppError::from(err.to_string()))?; } - upload.flush().await.map_err(|err| AppError(err.to_string()))?; + upload.flush().await.map_err(|err| AppError::from(err.to_string()))?; drop(upload); - let plan = inspect_offline_zip(&zip_path).map_err(AppError)?; - ensure_no_offline_import_blockers(&state.app, &plan).await.map_err(AppError)?; + let plan = inspect_offline_zip(&zip_path).map_err(AppError::from)?; + ensure_no_offline_import_blockers(&state.app, &plan).await.map_err(AppError::from)?; import_agents_from_zip_core(&state.app.agent_manager, &zip_path, |event| send_progress_event(&tx, event)) - .map_err(AppError) + .map_err(AppError::from) } .await; let _ = std::fs::remove_file(&zip_path); @@ -201,7 +201,7 @@ pub async fn import_agents_from_zip( return Ok(Json(serde_json::json!({ "count": result.drivers_installed.len() as u32 }))); } - Err(AppError("No file uploaded".to_string())) + Err(AppError::from("No file uploaded".to_string())) } pub async fn import_agent_driver_file( @@ -215,28 +215,28 @@ pub async fn import_agent_driver_file( while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name == "dbType" { - db_type = Some(field.text().await.map_err(|e| AppError(e.to_string()))?); + db_type = Some(field.text().await.map_err(|e| AppError::from(e.to_string()))?); } else if name == "file" { driver_name = field.file_name().unwrap_or("agent").to_string(); - driver_data = Some(field.bytes().await.map_err(|e| AppError(e.to_string()))?.to_vec()); + driver_data = Some(field.bytes().await.map_err(|e| AppError::from(e.to_string()))?.to_vec()); } } - let db_type = db_type.ok_or_else(|| AppError("Missing dbType field".to_string()))?; - let data = driver_data.ok_or_else(|| AppError("No file uploaded".to_string()))?; + let db_type = db_type.ok_or_else(|| AppError::from("Missing dbType field".to_string()))?; + let data = driver_data.ok_or_else(|| AppError::from("No file uploaded".to_string()))?; let temp_dir = state.app.plugins.root_dir().join("agent_upload_tmp"); - std::fs::create_dir_all(&temp_dir).map_err(|e| AppError(e.to_string()))?; + std::fs::create_dir_all(&temp_dir).map_err(|e| AppError::from(e.to_string()))?; let suffix = std::path::Path::new(&driver_name) .extension() .and_then(|extension| extension.to_str()) .map(|extension| format!(".{extension}")) .unwrap_or_default(); let tmp_path = temp_dir.join(format!("agent-{}{}", uuid::Uuid::new_v4(), suffix)); - std::fs::write(&tmp_path, &data).map_err(|e| AppError(e.to_string()))?; + std::fs::write(&tmp_path, &data).map_err(|e| AppError::from(e.to_string()))?; let result = async { - ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&db_type)).await.map_err(AppError)?; + ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&db_type)).await.map_err(AppError::from)?; import_agent_driver(&state.app.agent_manager, &db_type, &tmp_path).map_err(AppError::from) } .await; @@ -254,7 +254,7 @@ pub async fn reinstall_jre( send_progress_event(&tx, event); }) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -263,7 +263,7 @@ pub async fn uninstall_jre( Json(req): Json, ) -> Result, AppError> { let key = req.jre_key.as_deref().unwrap_or(DEFAULT_JRE_KEY); - uninstall_agent_jre(&state.app.agent_manager, key).await.map_err(AppError)?; + uninstall_agent_jre(&state.app.agent_manager, key).await.map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index bab2115f6f..5a3050d364 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -113,12 +113,12 @@ pub async fn save_ai_config( Json(body): Json, ) -> Result, AppError> { reject_web_unsupported_ai_provider(&body.config)?; - state.app.storage.save_ai_config(&body.config).await.map_err(AppError)?; + state.app.storage.save_ai_config(&body.config).await.map_err(AppError::from)?; Ok(Json(())) } pub async fn load_ai_config(State(state): State>) -> Result>, AppError> { - let config = state.app.storage.load_ai_config().await.map_err(AppError)?; + let config = state.app.storage.load_ai_config().await.map_err(AppError::from)?; Ok(Json(config)) } @@ -130,14 +130,14 @@ pub async fn save_ai_provider_config( let mut config = body.config; config.provider = parsed_provider; reject_web_unsupported_ai_provider(&config)?; - state.app.storage.save_ai_provider_config(&body.provider, &config).await.map_err(AppError)?; + state.app.storage.save_ai_provider_config(&body.provider, &config).await.map_err(AppError::from)?; Ok(Json(())) } pub async fn load_ai_provider_configs( State(state): State>, ) -> Result>, AppError> { - let configs = state.app.storage.load_ai_provider_configs().await.map_err(AppError)?; + let configs = state.app.storage.load_ai_provider_configs().await.map_err(AppError::from)?; Ok(Json(configs)) } @@ -158,12 +158,12 @@ pub async fn save_ai_configs( for item in &body.configs { reject_web_unsupported_ai_provider(&item.config)?; } - state.app.storage.save_ai_configs(&body.configs).await.map_err(AppError)?; + state.app.storage.save_ai_configs(&body.configs).await.map_err(AppError::from)?; Ok(Json(())) } pub async fn load_ai_configs(State(state): State>) -> Result>, AppError> { - let configs = state.app.storage.load_ai_configs().await.map_err(AppError)?; + let configs = state.app.storage.load_ai_configs().await.map_err(AppError::from)?; Ok(Json(configs)) } @@ -177,7 +177,7 @@ pub async fn set_default_ai_config( State(state): State>, Json(body): Json, ) -> Result, AppError> { - state.app.storage.set_default_ai_config(&body.config_id).await.map_err(AppError)?; + state.app.storage.set_default_ai_config(&body.config_id).await.map_err(AppError::from)?; Ok(Json(())) } @@ -192,7 +192,7 @@ pub async fn save_ai_config_item( Json(body): Json, ) -> Result, AppError> { reject_web_unsupported_ai_provider(&body.config.config)?; - state.app.storage.save_ai_config_item(&body.config).await.map_err(AppError)?; + state.app.storage.save_ai_config_item(&body.config).await.map_err(AppError::from)?; Ok(Json(())) } @@ -200,7 +200,7 @@ pub async fn delete_ai_config( State(state): State>, Path(config_id): Path, ) -> Result, AppError> { - state.app.storage.delete_ai_config(&config_id).await.map_err(AppError)?; + state.app.storage.delete_ai_config(&config_id).await.map_err(AppError::from)?; Ok(Json(())) } @@ -212,12 +212,12 @@ pub async fn save_ai_conversation( State(state): State>, Json(body): Json, ) -> Result, AppError> { - state.app.storage.save_ai_conversation(&body.conversation).await.map_err(AppError)?; + state.app.storage.save_ai_conversation(&body.conversation).await.map_err(AppError::from)?; Ok(Json(())) } pub async fn load_ai_conversations(State(state): State>) -> Result>, AppError> { - let conversations = state.app.storage.load_ai_conversations().await.map_err(AppError)?; + let conversations = state.app.storage.load_ai_conversations().await.map_err(AppError::from)?; Ok(Json(conversations)) } @@ -225,7 +225,7 @@ pub async fn delete_ai_conversation( State(state): State>, Path(id): Path, ) -> Result, AppError> { - state.app.storage.delete_ai_conversation(&id).await.map_err(AppError)?; + state.app.storage.delete_ai_conversation(&id).await.map_err(AppError::from)?; Ok(Json(())) } @@ -235,7 +235,7 @@ pub async fn delete_ai_conversation( pub async fn ai_complete(Json(body): Json) -> Result, AppError> { reject_web_unsupported_ai_provider(&body.request.config)?; - let result = dbx_core::ai::complete(&body.request).await.map_err(AppError)?; + let result = dbx_core::ai::complete(&body.request).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -247,13 +247,13 @@ pub async fn ai_test_connection( Json(body): Json, ) -> Result, AppError> { reject_web_unsupported_ai_provider(&body.config)?; - let result = dbx_core::ai::test_connection_core(&body.config).await.map_err(AppError)?; + let result = dbx_core::ai::test_connection_core(&body.config).await.map_err(AppError::from)?; Ok(Json(result)) } pub async fn ai_list_models(Json(body): Json) -> Result>, AppError> { reject_web_unsupported_ai_provider(&body.config)?; - let result = dbx_core::ai::list_models_core(&body.config).await.map_err(AppError)?; + let result = dbx_core::ai::list_models_core(&body.config).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -323,7 +323,7 @@ pub async fn ai_agent_stream( let (tx, rx) = tokio::sync::broadcast::channel::(256); let parsed_db_type: DatabaseType = serde_json::from_str(&format!("\"{}\"", body.db_type)) - .map_err(|_| AppError(format!("Unknown database type: {}", body.db_type)))?; + .map_err(|_| AppError::from(format!("Unknown database type: {}", body.db_type)))?; let production_database = state .app .configs diff --git a/crates/dbx-web/src/routes/app_settings.rs b/crates/dbx-web/src/routes/app_settings.rs index fafdbdf837..9124479a65 100644 --- a/crates/dbx-web/src/routes/app_settings.rs +++ b/crates/dbx-web/src/routes/app_settings.rs @@ -39,7 +39,7 @@ pub struct DecryptConfigRequest { } pub async fn load_pinned_tree_node_ids(State(state): State>) -> Result>, AppError> { - let ids = state.app.storage.load_pinned_tree_node_ids().await.map_err(AppError)?; + let ids = state.app.storage.load_pinned_tree_node_ids().await.map_err(AppError::from)?; Ok(Json(ids)) } @@ -47,26 +47,26 @@ pub async fn save_pinned_tree_node_ids( State(state): State>, Json(body): Json, ) -> Result, AppError> { - state.app.storage.save_pinned_tree_node_ids(&body.ids).await.map_err(AppError)?; + state.app.storage.save_pinned_tree_node_ids(&body.ids).await.map_err(AppError::from)?; Ok(Json(())) } pub async fn load_mcp_global_policy( State(state): State>, ) -> Result, AppError> { - state.app.storage.load_mcp_global_policy().await.map(Json).map_err(AppError) + state.app.storage.load_mcp_global_policy().await.map(Json).map_err(AppError::from) } pub async fn save_mcp_global_policy( State(state): State>, Json(policy): Json, ) -> Result, AppError> { - state.app.storage.save_mcp_global_policy(&policy).await.map_err(AppError)?; + state.app.storage.save_mcp_global_policy(&policy).await.map_err(AppError::from)?; Ok(Json(())) } pub async fn decrypt_config(Json(body): Json) -> Result, AppError> { - decrypt_config_payload(&body.payload, &body.passphrase).map(Json).map_err(AppError) + decrypt_config_payload(&body.payload, &body.passphrase).map(Json).map_err(AppError::from) } fn decrypt_config_payload(payload: &EncryptedConfigPayload, passphrase: &str) -> Result { diff --git a/crates/dbx-web/src/routes/cloud_sync.rs b/crates/dbx-web/src/routes/cloud_sync.rs index c7b42cc418..8d00ad3895 100644 --- a/crates/dbx-web/src/routes/cloud_sync.rs +++ b/crates/dbx-web/src/routes/cloud_sync.rs @@ -102,8 +102,8 @@ pub async fn webdav_sync_test( State(state): State>, Json(mut req): Json, ) -> Result, AppError> { - resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError)?; - WebDavClient::new(req.config).test().await.map_err(AppError)?; + resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError::from)?; + WebDavClient::new(req.config).test().await.map_err(AppError::from)?; Ok(Json(())) } @@ -111,14 +111,14 @@ pub async fn webdav_password_status( State(state): State>, Json(req): Json, ) -> Result, AppError> { - webdav_saved_password_status(&state.app.storage, &req.config).await.map(Json).map_err(AppError) + webdav_saved_password_status(&state.app.storage, &req.config).await.map(Json).map_err(AppError::from) } pub async fn save_webdav_saved_password( State(state): State>, Json(req): Json, ) -> Result, AppError> { - save_webdav_password(&state.app.storage, &req.config, &req.password).await.map_err(AppError)?; + save_webdav_password(&state.app.storage, &req.config, &req.password).await.map_err(AppError::from)?; Ok(Json(())) } @@ -126,14 +126,14 @@ pub async fn forget_webdav_saved_password( State(state): State>, Json(req): Json, ) -> Result, AppError> { - forget_webdav_password(&state.app.storage, &req.config).await.map_err(AppError)?; + forget_webdav_password(&state.app.storage, &req.config).await.map_err(AppError::from)?; Ok(Json(())) } pub async fn webdav_sync_secrets_status( State(state): State>, ) -> Result, AppError> { - core_webdav_sync_secrets_status(&state.app.storage).await.map(Json).map_err(AppError) + core_webdav_sync_secrets_status(&state.app.storage).await.map(Json).map_err(AppError::from) } pub async fn save_webdav_sync_secrets_preference( @@ -142,12 +142,12 @@ pub async fn save_webdav_sync_secrets_preference( ) -> Result, AppError> { core_save_webdav_sync_secrets_preference(&state.app.storage, req.enabled, req.passphrase.as_deref()) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } pub async fn forget_webdav_sync_secrets_passphrase(State(state): State>) -> Result, AppError> { - core_forget_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError)?; + core_forget_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError::from)?; Ok(Json(())) } @@ -155,7 +155,7 @@ pub async fn webdav_sync_upload( State(state): State>, Json(mut req): Json, ) -> Result, AppError> { - resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError)?; + resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError::from)?; let snapshot = build_sync_snapshot_with_saved_secrets( &state.app.storage, env!("CARGO_PKG_VERSION"), @@ -163,21 +163,21 @@ pub async fn webdav_sync_upload( req.secrets_passphrase.as_deref(), ) .await - .map_err(AppError)?; - WebDavClient::new(req.config).put_snapshot(&snapshot).await.map(Json).map_err(AppError) + .map_err(AppError::from)?; + WebDavClient::new(req.config).put_snapshot(&snapshot).await.map(Json).map_err(AppError::from) } pub async fn webdav_sync_download( State(state): State>, Json(mut req): Json, ) -> Result, AppError> { - resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError)?; - let (snapshot, summary) = WebDavClient::new(req.config).get_snapshot().await.map_err(AppError)?; + resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError::from)?; + let (snapshot, summary) = WebDavClient::new(req.config).get_snapshot().await.map_err(AppError::from)?; let explicit_passphrase = req.secrets_passphrase.as_deref().map(str::trim).filter(|value| !value.is_empty()); let saved_passphrase = if explicit_passphrase.is_some() { None } else { - resolve_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError)? + resolve_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError::from)? }; let apply_summary = apply_sync_snapshot( &state.app.storage, @@ -185,7 +185,7 @@ pub async fn webdav_sync_download( ApplySnapshotOptions { secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()) }, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(WebDavDownloadResult { summary, editor_settings: snapshot.editor_settings, @@ -198,8 +198,8 @@ pub async fn snippet_sync_test( State(state): State>, Json(mut req): Json, ) -> Result, AppError> { - resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError)?; - SnippetSyncClient::new(req.config).test().await.map_err(AppError)?; + resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?; + SnippetSyncClient::new(req.config).test().await.map_err(AppError::from)?; Ok(Json(())) } @@ -207,14 +207,14 @@ pub async fn snippet_token_status( State(state): State>, Json(req): Json, ) -> Result, AppError> { - snippet_saved_token_status(&state.app.storage, &req.config).await.map(Json).map_err(AppError) + snippet_saved_token_status(&state.app.storage, &req.config).await.map(Json).map_err(AppError::from) } pub async fn save_snippet_saved_token( State(state): State>, Json(req): Json, ) -> Result, AppError> { - save_snippet_token(&state.app.storage, &req.config, &req.token).await.map_err(AppError)?; + save_snippet_token(&state.app.storage, &req.config, &req.token).await.map_err(AppError::from)?; Ok(Json(())) } @@ -222,7 +222,7 @@ pub async fn forget_snippet_saved_token( State(state): State>, Json(req): Json, ) -> Result, AppError> { - forget_snippet_token(&state.app.storage, &req.config).await.map_err(AppError)?; + forget_snippet_token(&state.app.storage, &req.config).await.map_err(AppError::from)?; Ok(Json(())) } @@ -230,7 +230,7 @@ pub async fn snippet_sync_upload( State(state): State>, Json(mut req): Json, ) -> Result, AppError> { - resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError)?; + resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?; let snapshot = build_sync_snapshot_with_saved_secrets( &state.app.storage, env!("CARGO_PKG_VERSION"), @@ -238,21 +238,21 @@ pub async fn snippet_sync_upload( req.secrets_passphrase.as_deref(), ) .await - .map_err(AppError)?; - SnippetSyncClient::new(req.config).put_snapshot(&snapshot).await.map(Json).map_err(AppError) + .map_err(AppError::from)?; + SnippetSyncClient::new(req.config).put_snapshot(&snapshot).await.map(Json).map_err(AppError::from) } pub async fn snippet_sync_download( State(state): State>, Json(mut req): Json, ) -> Result, AppError> { - resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError)?; - let (snapshot, summary) = SnippetSyncClient::new(req.config).get_snapshot().await.map_err(AppError)?; + resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?; + let (snapshot, summary) = SnippetSyncClient::new(req.config).get_snapshot().await.map_err(AppError::from)?; let explicit_passphrase = req.secrets_passphrase.as_deref().map(str::trim).filter(|value| !value.is_empty()); let saved_passphrase = if explicit_passphrase.is_some() { None } else { - resolve_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError)? + resolve_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError::from)? }; let apply_summary = apply_sync_snapshot( &state.app.storage, @@ -260,7 +260,7 @@ pub async fn snippet_sync_download( ApplySnapshotOptions { secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()) }, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(SnippetDownloadResult { summary, editor_settings: snapshot.editor_settings, diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 08ed66cb49..58b2bf5420 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -111,14 +111,14 @@ pub async fn test_connection( run_temporary_connection_test(&state.app, body.config, false) .await .map(|result| Json(result.message)) - .map_err(AppError) + .map_err(AppError::from) } pub async fn test_connection_with_info( State(state): State>, Json(body): Json, ) -> Result, AppError> { - run_temporary_connection_test(&state.app, body.config, true).await.map(Json).map_err(AppError) + run_temporary_connection_test(&state.app, body.config, true).await.map(Json).map_err(AppError::from) } pub async fn connect_db( @@ -132,7 +132,7 @@ pub async fn connect_db( &config.password, !config.attached_databases.is_empty(), ) - .map_err(AppError)?; + .map_err(AppError::from)?; } let app = &state.app; let connection_id = config.id.clone(); @@ -142,7 +142,7 @@ pub async fn connect_db( app.reset_connection_transport_for_config(&connection_id, &config).await; app.configs.write().await.insert(connection_id.clone(), config.clone()); - app.get_or_create_pool_for_connection_attempt(&connection_id, None, attempt).await.map_err(AppError)?; + app.get_or_create_pool_for_connection_attempt(&connection_id, None, attempt).await.map_err(AppError::from)?; Ok(Json(connection_id)) } @@ -151,7 +151,12 @@ pub async fn connected_database_info( State(state): State>, Json(body): Json, ) -> Result>, AppError> { - state.app.connection_database_info(&body.connection_id, body.database.as_deref()).await.map(Json).map_err(AppError) + state + .app + .connection_database_info(&body.connection_id, body.database.as_deref()) + .await + .map(Json) + .map_err(AppError::from) } pub async fn save_connection_database_info( @@ -163,7 +168,7 @@ pub async fn save_connection_database_info( .save_connection_database_info(&body.connection_id, body.database_info) .await .map(|_| Json(())) - .map_err(AppError) + .map_err(AppError::from) } pub async fn connection_final_proxy_port( @@ -172,7 +177,7 @@ pub async fn connection_final_proxy_port( ) -> Result, AppError> { let runtime_config = body.config.canonicalized(); if !runtime_config.has_effective_transport_layers() { - return Err(AppError("Connection has no configured transport layers".to_string())); + return Err(AppError::from("Connection has no configured transport layers".to_string())); } if runtime_config.db_type == dbx_core::models::connection::DatabaseType::Sqlite { dbx_core::db::sqlite::validate_persistent_attachments( @@ -180,7 +185,7 @@ pub async fn connection_final_proxy_port( &runtime_config.password, !runtime_config.attached_databases.is_empty(), ) - .map_err(AppError)?; + .map_err(AppError::from)?; } let app = &state.app; @@ -188,7 +193,7 @@ pub async fn connection_final_proxy_port( let db_config = dbx_core::connection::metadata_connection_config(&runtime_config); app.configs.write().await.insert(connection_id.clone(), runtime_config); - let (_, port) = app.connection_host_port(&connection_id, &db_config).await.map_err(AppError)?; + let (_, port) = app.connection_host_port(&connection_id, &db_config).await.map_err(AppError::from)?; Ok(Json(port)) } @@ -224,7 +229,7 @@ pub async fn check_connection_health( State(state): State>, Json(body): Json, ) -> Result, AppError> { - state.app.check_connection_health(&body.connection_id).await.map_err(AppError)?; + state.app.check_connection_health(&body.connection_id).await.map_err(AppError::from)?; Ok(Json(())) } @@ -237,7 +242,7 @@ pub async fn connection_identifier_quote( .connection_identifier_quote(&body.connection_id, body.database.as_deref()) .await .map(Json) - .map_err(AppError) + .map_err(AppError::from) } pub async fn close_database_connection( @@ -246,7 +251,7 @@ pub async fn close_database_connection( ) -> Result, AppError> { let database = body.database.trim(); let database = if database.is_empty() { None } else { Some(database) }; - state.app.close_database_pool(&body.connection_id, database).await.map(Json).map_err(AppError) + state.app.close_database_pool(&body.connection_id, database).await.map(Json).map_err(AppError::from) } pub async fn save_connections( @@ -260,10 +265,10 @@ pub async fn save_connections( &config.password, !config.attached_databases.is_empty(), ) - .map_err(AppError)?; + .map_err(AppError::from)?; } } - state.app.storage.save_connections(&body.configs).await.map_err(AppError)?; + state.app.storage.save_connections(&body.configs).await.map_err(AppError::from)?; let sync = sync_connection_configs(&state, &body.configs).await; remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await; drop_nacos_adapters_for_connection_ids(&state, &sync.nacos_adapter_ids_to_drop).await; @@ -275,7 +280,7 @@ pub async fn mcp_add_connection( State(state): State>, Json(body): Json, ) -> Result, AppError> { - let saved = state.app.storage.add_connection_for_mcp(body.config).await.map_err(AppError)?; + let saved = state.app.storage.add_connection_for_mcp(body.config).await.map_err(AppError::from)?; state.app.configs.write().await.insert(saved.id.clone(), saved.clone()); Ok(Json(saved)) } @@ -285,7 +290,7 @@ pub async fn mcp_remove_connection( Json(body): Json, ) -> Result, AppError> { let connection_id = body.connection_id; - let removed = state.app.storage.remove_connection_for_mcp(&connection_id).await.map_err(AppError)?; + let removed = state.app.storage.remove_connection_for_mcp(&connection_id).await.map_err(AppError::from)?; if removed { state.app.configs.write().await.remove(&connection_id); state.app.remove_connection_pools_detached(&connection_id).await; @@ -297,7 +302,7 @@ pub async fn mcp_remove_connection( } pub async fn load_connections(State(state): State>) -> Result>, AppError> { - let configs = state.app.storage.load_connections().await.map_err(AppError)?; + let configs = state.app.storage.load_connections().await.map_err(AppError::from)?; let sync = sync_connection_configs(&state, &configs).await; remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await; drop_nacos_adapters_for_connection_ids(&state, &sync.nacos_adapter_ids_to_drop).await; @@ -507,13 +512,13 @@ mod tests { Json(ConnectRequest { config: config.clone(), client_attempt: None }), ) .await - .unwrap_or_else(|error| panic!("{}", error.0)); + .unwrap_or_else(|error| panic!("{}", error.message)); assert_eq!(legacy.0, "Connection successful"); let detailed = test_connection_with_info(State(state.clone()), Json(ConnectRequest { config, client_attempt: None })) .await - .unwrap_or_else(|error| panic!("{}", error.0)); + .unwrap_or_else(|error| panic!("{}", error.message)); assert_eq!(detailed.0.message, "Connection successful"); assert_eq!(detailed.0.database_info, None); assert!(state.app.configs.read().await.keys().all(|key| !key.starts_with("__test_"))); @@ -545,7 +550,7 @@ mod tests { connect_db(State(state.clone()), Json(ConnectRequest { config: invalid.clone(), client_attempt: None })) .await .unwrap_err(); - assert!(connect_error.0.contains("in-memory main database"), "{}", connect_error.0); + assert!(connect_error.message.contains("in-memory main database"), "{}", connect_error.message); invalid.transport_layers.push(TransportLayerConfig::Proxy(ProxyTunnelConfig { id: "proxy".to_string(), @@ -565,7 +570,7 @@ mod tests { ) .await .unwrap_err(); - assert!(proxy_error.0.contains("in-memory main database"), "{}", proxy_error.0); + assert!(proxy_error.message.contains("in-memory main database"), "{}", proxy_error.message); assert!(state.app.connections.read().await.contains_key(&initial.id)); assert_eq!(state.app.configs.read().await.get(&initial.id), Some(&initial)); @@ -681,7 +686,7 @@ mod tests { Json(McpRemoveConnectionRequest { connection_id: removed.id.clone() }), ) .await - .unwrap_or_else(|error| panic!("{}", error.0)); + .unwrap_or_else(|error| panic!("{}", error.message)); assert!(removed_result.0); assert_eq!(state.app.storage.load_connections().await.unwrap()[0].id, kept.id); @@ -691,7 +696,7 @@ mod tests { ) .await .unwrap_err(); - assert!(scope_error.0.starts_with("CONNECTION_OUT_OF_SCOPE:")); + assert!(scope_error.message.starts_with("CONNECTION_OUT_OF_SCOPE:")); state .app @@ -709,7 +714,7 @@ mod tests { ) .await .unwrap_err(); - assert!(read_only_error.0.starts_with("MCP_READ_ONLY:")); + assert!(read_only_error.message.starts_with("MCP_READ_ONLY:")); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/dbx-web/src/routes/data_compare.rs b/crates/dbx-web/src/routes/data_compare.rs index 8d58b6cde3..b2d80ceba4 100644 --- a/crates/dbx-web/src/routes/data_compare.rs +++ b/crates/dbx-web/src/routes/data_compare.rs @@ -8,21 +8,27 @@ use crate::state::WebState; pub async fn prepare_data_compare( Json(options): Json, ) -> Result, AppError> { - dbx_core::data_compare::prepare_data_compare(options).map(Json).map_err(AppError) + dbx_core::data_compare::prepare_data_compare(options).map(Json).map_err(AppError::from) } pub async fn prepare_data_compare_from_tables( State(state): State>, Json(options): Json, ) -> Result, AppError> { - dbx_core::data_compare::prepare_data_compare_from_tables(&state.app, options).await.map(Json).map_err(AppError) + dbx_core::data_compare::prepare_data_compare_from_tables(&state.app, options) + .await + .map(Json) + .map_err(AppError::from) } pub async fn prepare_data_compare_missing_target( State(state): State>, Json(options): Json, ) -> Result, AppError> { - dbx_core::data_compare::prepare_data_compare_missing_target(&state.app, options).await.map(Json).map_err(AppError) + dbx_core::data_compare::prepare_data_compare_missing_target(&state.app, options) + .await + .map(Json) + .map_err(AppError::from) } pub async fn build_data_compare_sync_plan( diff --git a/crates/dbx-web/src/routes/database_export.rs b/crates/dbx-web/src/routes/database_export.rs index 58b808d100..c33cbc252e 100644 --- a/crates/dbx-web/src/routes/database_export.rs +++ b/crates/dbx-web/src/routes/database_export.rs @@ -40,7 +40,7 @@ pub async fn start_database_export( // CWD, unreachable by the browser). The file is served back via the download // endpoint after the export completes. let tmp_dir = state.data_dir.join("tmp"); - std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?; + std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?; let tmp_file = tmp_dir.join(format!("database_export_{export_id}.sql")); let tmp_file_path = tmp_file.to_string_lossy().to_string(); req.file_path = tmp_file_path.clone(); @@ -128,7 +128,7 @@ pub async fn database_export_progress( Path(export_id): Path, ) -> Result>>, AppError> { let channels = state.sse_channels.read().await; - let tx = channels.get(&export_id).ok_or_else(|| AppError("Export not found".to_string()))?; + let tx = channels.get(&export_id).ok_or_else(|| AppError::from("Export not found".to_string()))?; let rx = tx.subscribe(); drop(channels); Ok(crate::sse::sse_from_channel(rx)) @@ -151,9 +151,9 @@ pub async fn database_export_download( .write() .await .remove(&export_id) - .ok_or_else(|| AppError("Export file not found".to_string()))?; + .ok_or_else(|| AppError::from("Export file not found".to_string()))?; - let data = tokio::fs::read(&file_path).await.map_err(|e| AppError(e.to_string()))?; + let data = tokio::fs::read(&file_path).await.map_err(|e| AppError::from(e.to_string()))?; // Clean up temp file let _ = tokio::fs::remove_file(&file_path).await; @@ -162,5 +162,5 @@ pub async fn database_export_download( .header(header::CONTENT_TYPE, "application/sql; charset=utf-8") .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{download_filename}\"")) .body(Body::from(data)) - .map_err(|e| AppError(e.to_string())) + .map_err(|e| AppError::from(e.to_string())) } diff --git a/crates/dbx-web/src/routes/document_store.rs b/crates/dbx-web/src/routes/document_store.rs index a17063126f..0afd6cda5d 100644 --- a/crates/dbx-web/src/routes/document_store.rs +++ b/crates/dbx-web/src/routes/document_store.rs @@ -20,11 +20,11 @@ where let token = query.token(); tokio::select! { biased; - _ = token.cancelled() => Err(AppError(dbx_core::query::canceled_error())), - result = future => result.map_err(AppError), + _ = token.cancelled() => Err(AppError::from(dbx_core::query::canceled_error())), + result = future => result.map_err(AppError::from), } } else { - future.await.map_err(AppError) + future.await.map_err(AppError::from) } } @@ -34,7 +34,7 @@ async fn ensure_writable( action: &str, ) -> Result<(), AppError> { if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await { - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", name, action ))); @@ -149,7 +149,8 @@ pub async fn list_databases( State(state): State>, Json(req): Json, ) -> Result>, AppError> { - let result = dbx_core::document_ops::list_databases_core(&state.app, &req.connection_id).await.map_err(AppError)?; + let result = + dbx_core::document_ops::list_databases_core(&state.app, &req.connection_id).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -159,7 +160,7 @@ pub async fn list_collections( ) -> Result>, AppError> { let result = dbx_core::document_ops::list_collections_core(&state.app, &req.connection_id, &req.database) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -200,7 +201,7 @@ pub async fn insert_document( req.routing.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -219,7 +220,7 @@ pub async fn update_document( req.routing.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -237,7 +238,7 @@ pub async fn delete_document( req.routing.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -254,7 +255,7 @@ pub async fn list_gridfs_files( req.sort.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -270,7 +271,7 @@ pub async fn list_gridfs_buckets( req.sort.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -281,7 +282,7 @@ pub async fn create_gridfs_bucket( ensure_writable(&state.app, &req.connection_id, "Create GridFS bucket").await?; dbx_core::document_ops::create_gridfs_bucket_core(&state.app, &req.connection_id, &req.database, &req.bucket) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -292,7 +293,7 @@ pub async fn delete_gridfs_bucket( ensure_writable(&state.app, &req.connection_id, "Delete GridFS bucket").await?; dbx_core::document_ops::delete_gridfs_bucket_core(&state.app, &req.connection_id, &req.database, &req.bucket) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -308,7 +309,7 @@ pub async fn download_gridfs_file( &req.file_id, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -323,14 +324,14 @@ pub async fn upload_gridfs_file( let mut content_type: Option = None; let mut file_bytes: Option> = None; - while let Some(field) = multipart.next_field().await.map_err(|e| AppError(e.to_string()))? { + while let Some(field) = multipart.next_field().await.map_err(|e| AppError::from(e.to_string()))? { let name = field.name().unwrap_or_default().to_string(); match name.as_str() { - "connectionId" => connection_id = Some(field.text().await.map_err(|e| AppError(e.to_string()))?), - "database" => database = Some(field.text().await.map_err(|e| AppError(e.to_string()))?), - "bucket" => bucket = Some(field.text().await.map_err(|e| AppError(e.to_string()))?), - "fileName" => file_name = Some(field.text().await.map_err(|e| AppError(e.to_string()))?), - "contentType" => content_type = Some(field.text().await.map_err(|e| AppError(e.to_string()))?), + "connectionId" => connection_id = Some(field.text().await.map_err(|e| AppError::from(e.to_string()))?), + "database" => database = Some(field.text().await.map_err(|e| AppError::from(e.to_string()))?), + "bucket" => bucket = Some(field.text().await.map_err(|e| AppError::from(e.to_string()))?), + "fileName" => file_name = Some(field.text().await.map_err(|e| AppError::from(e.to_string()))?), + "contentType" => content_type = Some(field.text().await.map_err(|e| AppError::from(e.to_string()))?), "file" => { if file_name.is_none() { file_name = field.file_name().map(str::to_string); @@ -338,7 +339,7 @@ pub async fn upload_gridfs_file( if content_type.is_none() { content_type = field.content_type().map(str::to_string); } - file_bytes = Some(field.bytes().await.map_err(|e| AppError(e.to_string()))?.to_vec()); + file_bytes = Some(field.bytes().await.map_err(|e| AppError::from(e.to_string()))?.to_vec()); } _ => { let _ = field.bytes().await; @@ -346,11 +347,11 @@ pub async fn upload_gridfs_file( } } - let connection_id = connection_id.ok_or_else(|| AppError("Missing connectionId".to_string()))?; - let database = database.ok_or_else(|| AppError("Missing database".to_string()))?; - let bucket = bucket.ok_or_else(|| AppError("Missing bucket".to_string()))?; - let file_name = file_name.ok_or_else(|| AppError("Missing fileName".to_string()))?; - let file_bytes = file_bytes.ok_or_else(|| AppError("No file uploaded".to_string()))?; + let connection_id = connection_id.ok_or_else(|| AppError::from("Missing connectionId".to_string()))?; + let database = database.ok_or_else(|| AppError::from("Missing database".to_string()))?; + let bucket = bucket.ok_or_else(|| AppError::from("Missing bucket".to_string()))?; + let file_name = file_name.ok_or_else(|| AppError::from("Missing fileName".to_string()))?; + let file_bytes = file_bytes.ok_or_else(|| AppError::from("No file uploaded".to_string()))?; ensure_writable(&state.app, &connection_id, "Upload GridFS file").await?; let result = dbx_core::document_ops::upload_gridfs_file_core( @@ -363,7 +364,7 @@ pub async fn upload_gridfs_file( content_type.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -380,6 +381,6 @@ pub async fn delete_gridfs_file( &req.file_id, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } diff --git a/crates/dbx-web/src/routes/etcd.rs b/crates/dbx-web/src/routes/etcd.rs index 3a19871240..0c6073d709 100644 --- a/crates/dbx-web/src/routes/etcd.rs +++ b/crates/dbx-web/src/routes/etcd.rs @@ -14,7 +14,7 @@ async fn ensure_writable( action: &str, ) -> Result<(), AppError> { if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await { - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", name, action ))); @@ -59,7 +59,7 @@ pub async fn list_prefix( req.continuation.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -67,7 +67,8 @@ pub async fn get( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let result = dbx_core::agent_kv::kv_get_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?; + let result = + dbx_core::agent_kv::kv_get_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -78,7 +79,7 @@ pub async fn put( ensure_writable(&state.app, &req.connection_id, "Put").await?; let result = dbx_core::agent_kv::kv_put_core(&state.app, &req.connection_id, &req.key, req.value, req.lease) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -88,6 +89,6 @@ pub async fn delete( ) -> Result, AppError> { ensure_writable(&state.app, &req.connection_id, "Delete").await?; let result = - dbx_core::agent_kv::kv_delete_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?; + dbx_core::agent_kv::kv_delete_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError::from)?; Ok(Json(result)) } diff --git a/crates/dbx-web/src/routes/history.rs b/crates/dbx-web/src/routes/history.rs index b84e0ecaa9..936ba46330 100644 --- a/crates/dbx-web/src/routes/history.rs +++ b/crates/dbx-web/src/routes/history.rs @@ -25,7 +25,7 @@ pub async fn save_history( State(state): State>, Json(body): Json, ) -> Result, AppError> { - state.app.storage.save_history_entry(&body.entry).await.map_err(AppError)?; + state.app.storage.save_history_entry(&body.entry).await.map_err(AppError::from)?; Ok(Json(())) } @@ -35,7 +35,8 @@ pub async fn load_history( ) -> Result>, AppError> { let limit = q.limit.unwrap_or(100); let offset = q.offset.unwrap_or(0); - let entries = state.app.storage.load_history_entries(limit, offset, q.activity_kind).await.map_err(AppError)?; + let entries = + state.app.storage.load_history_entries(limit, offset, q.activity_kind).await.map_err(AppError::from)?; Ok(Json(entries)) } @@ -43,17 +44,17 @@ pub async fn search_history( State(state): State>, Json(request): Json, ) -> Result, AppError> { - state.app.storage.search_history_entries(request).await.map(Json).map_err(AppError) + state.app.storage.search_history_entries(request).await.map(Json).map_err(AppError::from) } pub async fn load_history_connection_options( State(state): State>, ) -> Result>, AppError> { - state.app.storage.load_history_connection_options().await.map(Json).map_err(AppError) + state.app.storage.load_history_connection_options().await.map(Json).map_err(AppError::from) } pub async fn clear_history(State(state): State>) -> Result, AppError> { - state.app.storage.clear_history().await.map_err(AppError)?; + state.app.storage.clear_history().await.map_err(AppError::from)?; Ok(Json(())) } @@ -61,6 +62,6 @@ pub async fn delete_history_entry( State(state): State>, Path(id): Path, ) -> Result, AppError> { - state.app.storage.delete_history_entry(&id).await.map_err(AppError)?; + state.app.storage.delete_history_entry(&id).await.map_err(AppError::from)?; Ok(Json(())) } diff --git a/crates/dbx-web/src/routes/layout.rs b/crates/dbx-web/src/routes/layout.rs index afc9b0defd..1a7c54e301 100644 --- a/crates/dbx-web/src/routes/layout.rs +++ b/crates/dbx-web/src/routes/layout.rs @@ -17,11 +17,11 @@ pub async fn save_sidebar_layout( State(state): State>, Json(body): Json, ) -> Result, AppError> { - state.app.storage.save_sidebar_layout(&body.layout).await.map_err(AppError)?; + state.app.storage.save_sidebar_layout(&body.layout).await.map_err(AppError::from)?; Ok(Json(())) } pub async fn load_sidebar_layout(State(state): State>) -> Result, AppError> { - let layout = state.app.storage.load_sidebar_layout().await.map_err(AppError)?; + let layout = state.app.storage.load_sidebar_layout().await.map_err(AppError::from)?; Ok(Json(layout.unwrap_or(serde_json::json!(null)))) } diff --git a/crates/dbx-web/src/routes/mcp_policy.rs b/crates/dbx-web/src/routes/mcp_policy.rs index 6c4c2e782e..f5f65e0b1f 100644 --- a/crates/dbx-web/src/routes/mcp_policy.rs +++ b/crates/dbx-web/src/routes/mcp_policy.rs @@ -228,12 +228,12 @@ fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool { } async fn load_policy(state: &Arc) -> Result { - state.app.storage.load_mcp_global_policy().await.map(|state| state.policy()).map_err(AppError) + state.app.storage.load_mcp_global_policy().await.map(|state| state.policy()).map_err(AppError::from) } fn ensure_allowed(policy: &McpGlobalPolicy, connection_id: &str) -> Result<(), AppError> { if policy.allowed_connection_ids.as_ref().is_some_and(|allowed| !allowed.iter().any(|id| id == connection_id)) { - return Err(AppError(format!( + return Err(AppError::from(format!( "CONNECTION_OUT_OF_SCOPE: connection '{connection_id}' is not allowed by DBX MCP settings" ))); } @@ -241,7 +241,7 @@ fn ensure_allowed(policy: &McpGlobalPolicy, connection_id: &str) -> Result<(), A } fn connection_read_only_error(message: impl Into) -> AppError { - AppError(format!("CONNECTION_READ_ONLY: {}", message.into())) + AppError::from(format!("CONNECTION_READ_ONLY: {}", message.into())) } async fn load_connection(state: &Arc, connection_id: &str) -> Result { @@ -250,10 +250,10 @@ async fn load_connection(state: &Arc, connection_id: &str) -> Result, headers: &HeaderMap, connection_id: &str) -> Result<(), AppError> { @@ -295,7 +295,7 @@ pub async fn ensure_mongo_pipeline_target( } let config = load_connection(state, connection_id).await?; if dbx_core::production_safety::mongo_pipeline_targets_production_database(&config, database, pipeline_json) { - return Err(AppError( + return Err(AppError::from( "PRODUCTION_DATABASE_READ_ONLY: MongoDB aggregate write targeting production scope is blocked.".to_string(), )); } @@ -316,10 +316,12 @@ async fn ensure_write_with_risk( let policy = load_policy(state).await?; ensure_allowed(&policy, connection_id)?; if policy.read_only { - return Err(AppError(format!("MCP_READ_ONLY: DBX MCP read-only mode is enabled. {action} blocked."))); + return Err(AppError::from(format!("MCP_READ_ONLY: DBX MCP read-only mode is enabled. {action} blocked."))); } if dangerous && !policy.allow_dangerous_sql { - return Err(AppError(format!("SQL_BLOCKED: High-risk operation '{action}' is disabled in DBX MCP settings."))); + return Err(AppError::from(format!( + "SQL_BLOCKED: High-risk operation '{action}' is disabled in DBX MCP settings." + ))); } let config = load_connection(state, connection_id).await?; if config.read_only { @@ -329,7 +331,7 @@ async fn ensure_write_with_risk( ))); } if dbx_core::production_safety::is_production_database(&config, database) { - return Err(AppError(format!( + return Err(AppError::from(format!( "PRODUCTION_DATABASE_READ_ONLY: {action} blocked for production database '{database}'." ))); } @@ -350,21 +352,23 @@ pub async fn ensure_sql( ensure_allowed(&policy, connection_id)?; let config = load_connection(state, connection_id).await?; if dbx_core::sql_risk::mcp_sql_has_forbidden_database_switch(sql, config.db_type) { - return Err(AppError("SQL_BLOCKED: MCP does not allow USE or persistent database switching.".to_string())); + return Err(AppError::from( + "SQL_BLOCKED: MCP does not allow USE or persistent database switching.".to_string(), + )); } let is_write = dbx_core::query_execution_sql::is_write_sql_for_database(sql, config.db_type); if policy.read_only && is_write { - return Err(AppError("MCP_READ_ONLY: DBX MCP read-only mode is enabled. SQL write blocked.".to_string())); + return Err(AppError::from("MCP_READ_ONLY: DBX MCP read-only mode is enabled. SQL write blocked.".to_string())); } if !policy.allow_dangerous_sql && dbx_core::sql_risk::is_dangerous_sql_for_database(sql, config.db_type) { - return Err(AppError("SQL_BLOCKED: High-risk SQL is disabled in DBX MCP settings.".to_string())); + return Err(AppError::from("SQL_BLOCKED: High-risk SQL is disabled in DBX MCP settings.".to_string())); } if config.read_only { dbx_core::query_execution_sql::check_read_only(sql, &config.name, config.db_type) .map_err(connection_read_only_error)?; } if is_write && dbx_core::production_safety::targets_production_database(&config, database, sql) { - return Err(AppError( + return Err(AppError::from( "PRODUCTION_DATABASE_READ_ONLY: SQL write targeting production scope is blocked.".to_string(), )); } @@ -377,7 +381,7 @@ mod tests { #[test] fn connection_read_only_errors_use_the_stable_mcp_code() { - assert_eq!(connection_read_only_error("write blocked").0, "CONNECTION_READ_ONLY: write blocked"); + assert_eq!(connection_read_only_error("write blocked").message, "CONNECTION_READ_ONLY: write blocked"); } #[test] diff --git a/crates/dbx-web/src/routes/mod.rs b/crates/dbx-web/src/routes/mod.rs index 09457efcbd..19be22fff4 100644 --- a/crates/dbx-web/src/routes/mod.rs +++ b/crates/dbx-web/src/routes/mod.rs @@ -16,6 +16,7 @@ pub mod mongo; pub mod mq; pub mod nacos; pub mod plugins; +pub mod prompt_template; pub mod query; pub mod query_result_export; pub mod redis; diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 02e2b0925b..a7323bb1f9 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -18,7 +18,7 @@ pub struct MongoParseShellCommandRequest { pub async fn parse_shell_command( Json(req): Json, ) -> Result, AppError> { - dbx_core::mongo_shell::parse(&req.source).map(Json).map_err(AppError) + dbx_core::mongo_shell::parse(&req.source).map(Json).map_err(AppError::from) } async fn run_cancellable(state: &Arc, execution_id: Option, future: F) -> Result @@ -33,11 +33,11 @@ where let token = query.token(); tokio::select! { biased; - _ = token.cancelled() => Err(AppError(dbx_core::query::canceled_error())), - result = future => result.map_err(AppError), + _ = token.cancelled() => Err(AppError::from(dbx_core::query::canceled_error())), + result = future => result.map_err(AppError::from), } } else { - future.await.map_err(AppError) + future.await.map_err(AppError::from) } } @@ -48,7 +48,7 @@ async fn ensure_writable( action: &str, ) -> Result<(), AppError> { if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await { - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", name, action ))); @@ -284,7 +284,7 @@ pub async fn list_databases( ) -> Result>, AppError> { super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?; let result = - dbx_core::mongo_ops::mongo_list_databases_core(&state.app, &req.connection_id).await.map_err(AppError)?; + dbx_core::mongo_ops::mongo_list_databases_core(&state.app, &req.connection_id).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -296,7 +296,7 @@ pub async fn list_collections( super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?; let result = dbx_core::mongo_ops::mongo_list_collections_core(&state.app, &req.connection_id, &req.database) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -319,7 +319,7 @@ pub async fn vector_collection_detail( &req.collection, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -330,7 +330,7 @@ pub async fn create_database( ensure_writable(&state.app, &req.connection_id, "Create database").await?; dbx_core::mongo_ops::mongo_create_database_core(&state.app, &req.connection_id, &req.database) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -341,7 +341,7 @@ pub async fn drop_database( ensure_writable(&state.app, &req.connection_id, "Drop database").await?; dbx_core::mongo_ops::mongo_drop_database_core(&state.app, &req.connection_id, &req.database) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -355,7 +355,7 @@ pub async fn drop_collection( ensure_writable(&state.app, &req.connection_id, "Drop collection").await?; dbx_core::mongo_ops::mongo_drop_collection_core(&state.app, &req.connection_id, &req.database, &req.collection) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -375,7 +375,7 @@ pub async fn rename_collection( &req.new_name, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -401,7 +401,7 @@ pub async fn find_documents( ), ) .await?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn find_one( @@ -422,7 +422,7 @@ pub async fn find_one( ), ) .await?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn count_documents( @@ -459,7 +459,7 @@ pub async fn server_version( dbx_core::mongo_ops::mongo_server_version_core(&state.app, &req.connection_id, &req.database), ) .await?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn collection_stats( @@ -480,7 +480,7 @@ pub async fn collection_stats( ), ) .await?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn aggregate_documents( @@ -521,7 +521,7 @@ pub async fn aggregate_documents( ), ) .await?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn distinct( @@ -543,7 +543,7 @@ pub async fn distinct( ), ) .await?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn create_index( @@ -563,7 +563,7 @@ pub async fn create_index( req.options_json.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "name": name }))) } @@ -584,8 +584,8 @@ pub async fn drop_indexes( req.single, ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn insert_document( @@ -602,7 +602,7 @@ pub async fn insert_document( None, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -621,7 +621,7 @@ pub async fn insert_documents( &req.docs_json, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "affected_rows": result }))) } @@ -640,7 +640,7 @@ pub async fn update_document( req.routing.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -667,7 +667,7 @@ pub async fn update_documents( req.options_json.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "affected_rows": result }))) } @@ -689,8 +689,8 @@ pub async fn find_one_and_update( req.options_json.as_deref(), ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn find_one_and_replace( @@ -711,8 +711,8 @@ pub async fn find_one_and_replace( req.options_json.as_deref(), ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn find_one_and_delete( @@ -732,8 +732,8 @@ pub async fn find_one_and_delete( req.options_json.as_deref(), ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn delete_document( @@ -750,7 +750,7 @@ pub async fn delete_document( req.routing.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -775,7 +775,7 @@ pub async fn delete_documents( req.many, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "affected_rows": result }))) } @@ -873,12 +873,12 @@ mod tests { let revoked = ensure_find_one_write_policy(&state, &headers, &connection.id, "app", r#"{"_id":1}"#, "Update") .await .unwrap_err(); - assert!(revoked.0.starts_with("MCP_READ_ONLY:"), "{}", revoked.0); + assert!(revoked.message.starts_with("MCP_READ_ONLY:"), "{}", revoked.message); state.app.storage.save_mcp_global_policy(&writable_policy).await.unwrap(); let empty_filter = ensure_find_one_write_policy(&state, &headers, &connection.id, "app", "{}", "Delete").await.unwrap_err(); - assert!(empty_filter.0.starts_with("SQL_BLOCKED:"), "{}", empty_filter.0); + assert!(empty_filter.message.starts_with("SQL_BLOCKED:"), "{}", empty_filter.message); state .app @@ -893,7 +893,7 @@ mod tests { ensure_find_one_write_policy(&state, &headers, &connection.id, "app", r#"{"_id":1}"#, "Update") .await .unwrap_err(); - assert!(production.0.starts_with("PRODUCTION_DATABASE_READ_ONLY:"), "{}", production.0); + assert!(production.message.starts_with("PRODUCTION_DATABASE_READ_ONLY:"), "{}", production.message); state.app.storage.save_connections(std::slice::from_ref(&connection)).await.unwrap(); state @@ -909,7 +909,7 @@ mod tests { let allowlist = ensure_find_one_write_policy(&state, &headers, &connection.id, "app", r#"{"_id":1}"#, "Delete") .await .unwrap_err(); - assert!(allowlist.0.starts_with("CONNECTION_OUT_OF_SCOPE:"), "{}", allowlist.0); + assert!(allowlist.message.starts_with("CONNECTION_OUT_OF_SCOPE:"), "{}", allowlist.message); drop(state); let _ = std::fs::remove_dir_all(dir); diff --git a/crates/dbx-web/src/routes/mq.rs b/crates/dbx-web/src/routes/mq.rs index cd2b177fa8..a916a17e26 100644 --- a/crates/dbx-web/src/routes/mq.rs +++ b/crates/dbx-web/src/routes/mq.rs @@ -298,7 +298,7 @@ pub(crate) struct RawRequestReq { async fn ensure_writable(app: &dbx_core::connection::AppState, conn_id: &str, action: &str) -> Result<(), AppError> { if let Some(name) = dbx_core::query::connection_readonly_name(app, conn_id).await { - return Err(AppError(format!("Read-only connection '{name}'. {action} is blocked."))); + return Err(AppError::from(format!("Read-only connection '{name}'. {action} is blocked."))); } Ok(()) } @@ -310,7 +310,7 @@ pub async fn test_connection( Json(req): Json, ) -> Result, AppError> { let result = - dbx_core::mq::service::mq_test_connection_core(&state.app, &req.connection_id).await.map_err(AppError)?; + dbx_core::mq::service::mq_test_connection_core(&state.app, &req.connection_id).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -318,7 +318,8 @@ pub async fn list_tenants( State(state): State>, Json(req): Json, ) -> Result>, AppError> { - let result = dbx_core::mq::service::mq_list_tenants_core(&state.app, &req.connection_id).await.map_err(AppError)?; + let result = + dbx_core::mq::service::mq_list_tenants_core(&state.app, &req.connection_id).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -326,8 +327,9 @@ pub async fn get_tenant( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let result = - dbx_core::mq::service::mq_get_tenant_core(&state.app, &req.connection_id, &req.name).await.map_err(AppError)?; + let result = dbx_core::mq::service::mq_get_tenant_core(&state.app, &req.connection_id, &req.name) + .await + .map_err(AppError::from)?; Ok(Json(result)) } @@ -338,7 +340,7 @@ pub async fn create_tenant( ensure_writable(&state.app, &req.connection_id, "Create tenant").await?; dbx_core::mq::service::mq_create_tenant_core(&state.app, &req.connection_id, &req.name, req.config) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -349,7 +351,7 @@ pub async fn update_tenant( ensure_writable(&state.app, &req.connection_id, "Update tenant").await?; dbx_core::mq::service::mq_update_tenant_core(&state.app, &req.connection_id, &req.name, req.config) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -360,7 +362,7 @@ pub async fn delete_tenant( ensure_writable(&state.app, &req.connection_id, "Delete tenant").await?; dbx_core::mq::service::mq_delete_tenant_core(&state.app, &req.connection_id, &req.name, req.force) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -370,7 +372,7 @@ pub async fn list_namespaces( ) -> Result>, AppError> { let result = dbx_core::mq::service::mq_list_namespaces_core(&state.app, &req.connection_id, &req.tenant) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -381,7 +383,7 @@ pub async fn create_namespace( ensure_writable(&state.app, &req.connection_id, "Create namespace").await?; dbx_core::mq::service::mq_create_namespace_core(&state.app, &req.connection_id, req.ns, req.config) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -392,7 +394,7 @@ pub async fn delete_namespace( ensure_writable(&state.app, &req.connection_id, "Delete namespace").await?; dbx_core::mq::service::mq_delete_namespace_core(&state.app, &req.connection_id, req.ns, req.force) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -402,7 +404,7 @@ pub async fn get_namespace_policies( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_get_namespace_policies_core(&state.app, &req.connection_id, req.ns) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -412,7 +414,7 @@ pub async fn list_topics( ) -> Result>, AppError> { let result = dbx_core::mq::service::mq_list_topics_core(&state.app, &req.connection_id, req.ns, req.opts) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -423,7 +425,7 @@ pub async fn create_topic( ensure_writable(&state.app, &req.connection_id, "Create topic").await?; dbx_core::mq::service::mq_create_topic_core(&state.app, &req.connection_id, req.topic, req.partitions) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -434,7 +436,7 @@ pub async fn delete_topic( ensure_writable(&state.app, &req.connection_id, "Delete topic").await?; dbx_core::mq::service::mq_delete_topic_core(&state.app, &req.connection_id, req.topic, req.force) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -445,7 +447,7 @@ pub async fn update_partitions( ensure_writable(&state.app, &req.connection_id, "Update partitions").await?; dbx_core::mq::service::mq_update_partitions_core(&state.app, &req.connection_id, req.topic, req.partitions) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -455,7 +457,7 @@ pub async fn get_topic_stats( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_get_topic_stats_core(&state.app, &req.connection_id, req.topic) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -465,7 +467,7 @@ pub async fn get_topic_internal_stats( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_get_topic_internal_stats_core(&state.app, &req.connection_id, req.topic) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -475,7 +477,7 @@ pub async fn list_subscriptions( ) -> Result>, AppError> { let result = dbx_core::mq::service::mq_list_subscriptions_core(&state.app, &req.connection_id, req.topic) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -486,7 +488,7 @@ pub async fn create_subscription( ensure_writable(&state.app, &req.connection_id, "Create subscription").await?; dbx_core::mq::service::mq_create_subscription_core(&state.app, &req.connection_id, req.topic, req.sub, req.pos) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -497,7 +499,7 @@ pub async fn delete_subscription( ensure_writable(&state.app, &req.connection_id, "Delete subscription").await?; dbx_core::mq::service::mq_delete_subscription_core(&state.app, &req.connection_id, req.topic, req.sub, req.force) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -508,7 +510,7 @@ pub async fn skip_messages( ensure_writable(&state.app, &req.connection_id, "Skip messages").await?; dbx_core::mq::service::mq_skip_messages_core(&state.app, &req.connection_id, req.topic, req.sub, req.count) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -519,7 +521,7 @@ pub async fn reset_cursor( ensure_writable(&state.app, &req.connection_id, "Reset cursor").await?; dbx_core::mq::service::mq_reset_cursor_core(&state.app, &req.connection_id, req.topic, req.sub, req.pos) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -530,7 +532,7 @@ pub async fn clear_backlog( ensure_writable(&state.app, &req.connection_id, "Clear backlog").await?; dbx_core::mq::service::mq_clear_backlog_core(&state.app, &req.connection_id, req.topic, req.sub) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -540,7 +542,7 @@ pub async fn get_consumer_group_config( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_get_consumer_group_config_core(&state.app, &req.connection_id, req.group_id) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -556,7 +558,7 @@ pub async fn alter_consumer_group_config( req.config, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -573,7 +575,7 @@ pub async fn peek_messages( req.options, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -590,7 +592,7 @@ pub async fn expire_messages( req.expire_seconds, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -600,7 +602,7 @@ pub async fn list_producers( ) -> Result>, AppError> { let result = dbx_core::mq::service::mq_list_producers_core(&state.app, &req.connection_id, req.topic) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -610,13 +612,15 @@ pub async fn list_consumers( ) -> Result>, AppError> { let result = dbx_core::mq::service::mq_list_consumers_core(&state.app, &req.connection_id, req.topic, req.sub) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } pub async fn unload_topic(State(state): State>, Json(req): Json) -> Result, AppError> { ensure_writable(&state.app, &req.connection_id, "Unload topic").await?; - dbx_core::mq::service::mq_unload_topic_core(&state.app, &req.connection_id, req.topic).await.map_err(AppError)?; + dbx_core::mq::service::mq_unload_topic_core(&state.app, &req.connection_id, req.topic) + .await + .map_err(AppError::from)?; Ok(Json(())) } @@ -627,7 +631,7 @@ pub async fn set_publish_rate( ensure_writable(&state.app, &req.connection_id, "Set publish rate").await?; dbx_core::mq::service::mq_set_publish_rate_core(&state.app, &req.connection_id, req.scope, req.rate) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -638,7 +642,7 @@ pub async fn set_dispatch_rate( ensure_writable(&state.app, &req.connection_id, "Set dispatch rate").await?; dbx_core::mq::service::mq_set_dispatch_rate_core(&state.app, &req.connection_id, req.scope, req.rate) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -649,7 +653,7 @@ pub async fn set_subscribe_rate( ensure_writable(&state.app, &req.connection_id, "Set subscribe rate").await?; dbx_core::mq::service::mq_set_subscribe_rate_core(&state.app, &req.connection_id, req.scope, req.rate) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -660,7 +664,7 @@ pub async fn set_backlog_quota( ensure_writable(&state.app, &req.connection_id, "Set backlog quota").await?; dbx_core::mq::service::mq_set_backlog_quota_core(&state.app, &req.connection_id, req.scope, req.quota) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -671,7 +675,7 @@ pub async fn set_retention( ensure_writable(&state.app, &req.connection_id, "Set retention").await?; dbx_core::mq::service::mq_set_retention_core(&state.app, &req.connection_id, req.scope, req.retention) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -681,7 +685,7 @@ pub async fn get_effective_policies( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_get_effective_policies_core(&state.app, &req.connection_id, req.scope) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -692,7 +696,7 @@ pub async fn grant_permission( ensure_writable(&state.app, &req.connection_id, "Grant permission").await?; dbx_core::mq::service::mq_grant_permission_core(&state.app, &req.connection_id, req.scope, req.role, req.actions) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -703,7 +707,7 @@ pub async fn revoke_permission( ensure_writable(&state.app, &req.connection_id, "Revoke permission").await?; dbx_core::mq::service::mq_revoke_permission_core(&state.app, &req.connection_id, req.scope, req.role) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -713,7 +717,7 @@ pub async fn list_permissions( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_list_permissions_core(&state.app, &req.connection_id, req.scope) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -722,8 +726,9 @@ pub async fn issue_token( Json(req): Json, ) -> Result, AppError> { ensure_writable(&state.app, &req.connection_id, "Issue MQ token").await?; - let result = - dbx_core::mq::service::mq_issue_token_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?; + let result = dbx_core::mq::service::mq_issue_token_core(&state.app, &req.connection_id, req.req) + .await + .map_err(AppError::from)?; Ok(Json(result)) } @@ -733,7 +738,7 @@ pub async fn list_token_records( ) -> Result>, AppError> { let result = dbx_core::mq::service::mq_list_token_records_core(&state.app, &req.connection_id, req.subject) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -743,7 +748,7 @@ pub async fn get_backlog( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_get_backlog_core(&state.app, &req.connection_id, req.topic, req.sub) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -757,8 +762,9 @@ pub async fn get_cluster_info( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let result = - dbx_core::mq::service::mq_get_cluster_info_core(&state.app, &req.connection_id).await.map_err(AppError)?; + let result = dbx_core::mq::service::mq_get_cluster_info_core(&state.app, &req.connection_id) + .await + .map_err(AppError::from)?; Ok(Json(result)) } @@ -768,7 +774,7 @@ pub async fn get_topic_route( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_get_topic_route_core(&state.app, &req.connection_id, req.topic) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -787,7 +793,7 @@ pub async fn alter_topic_config( ensure_writable(&state.app, &req.connection_id, "Alter topic config").await?; dbx_core::mq::service::mq_alter_topic_config_core(&state.app, &req.connection_id, req.topic, req.configs) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -798,7 +804,7 @@ pub async fn skip_topic_accumulation( ensure_writable(&state.app, &req.connection_id, "Skip topic accumulation").await?; let result = dbx_core::mq::service::mq_skip_topic_accumulation_core(&state.app, &req.connection_id, req.topic) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -816,7 +822,7 @@ pub async fn view_message( ) -> Result, AppError> { let result = dbx_core::mq::service::mq_view_message_core(&state.app, &req.connection_id, req.topic, req.msg_id) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -845,7 +851,7 @@ pub async fn query_messages_by_key( req.max_num, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -872,7 +878,7 @@ pub async fn query_messages_by_topic( req.max_num, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -891,7 +897,7 @@ pub async fn query_message_trace( let result = dbx_core::mq::service::mq_query_message_trace_core(&state.app, &req.connection_id, req.msg_id, req.trace_topic) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -902,8 +908,9 @@ pub async fn raw_request( if req.req.is_mutating() { ensure_writable(&state.app, &req.connection_id, "MQ admin write").await?; } - let result = - dbx_core::mq::service::mq_raw_request_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?; + let result = dbx_core::mq::service::mq_raw_request_core(&state.app, &req.connection_id, req.req) + .await + .map_err(AppError::from)?; Ok(Json(result)) } @@ -920,7 +927,8 @@ pub async fn send_message( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let result = - dbx_core::mq::service::mq_send_message_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?; + let result = dbx_core::mq::service::mq_send_message_core(&state.app, &req.connection_id, req.req) + .await + .map_err(AppError::from)?; Ok(Json(result)) } diff --git a/crates/dbx-web/src/routes/nacos.rs b/crates/dbx-web/src/routes/nacos.rs index 67a8506212..34f93c1eef 100644 --- a/crates/dbx-web/src/routes/nacos.rs +++ b/crates/dbx-web/src/routes/nacos.rs @@ -100,8 +100,9 @@ pub async fn test_connection( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let result = - dbx_core::nacos::service::nacos_test_connection_core(&state.app, &req.connection_id).await.map_err(AppError)?; + let result = dbx_core::nacos::service::nacos_test_connection_core(&state.app, &req.connection_id) + .await + .map_err(AppError::from)?; Ok(Json(result)) } @@ -109,8 +110,9 @@ pub async fn list_namespaces( State(state): State>, Json(req): Json, ) -> Result>, AppError> { - let result = - dbx_core::nacos::service::nacos_list_namespaces_core(&state.app, &req.connection_id).await.map_err(AppError)?; + let result = dbx_core::nacos::service::nacos_list_namespaces_core(&state.app, &req.connection_id) + .await + .map_err(AppError::from)?; Ok(Json(result)) } @@ -120,7 +122,7 @@ pub async fn create_namespace( ) -> Result, AppError> { dbx_core::nacos::service::nacos_create_namespace_core(&state.app, &req.connection_id, req.req) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -130,7 +132,7 @@ pub async fn update_namespace( ) -> Result, AppError> { dbx_core::nacos::service::nacos_update_namespace_core(&state.app, &req.connection_id, req.req) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -140,7 +142,7 @@ pub async fn list_configs( ) -> Result, AppError> { let result = dbx_core::nacos::service::nacos_list_configs_core(&state.app, &req.connection_id, req.query) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -150,7 +152,7 @@ pub async fn get_config( ) -> Result, AppError> { let result = dbx_core::nacos::service::nacos_get_config_core(&state.app, &req.connection_id, req.key) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -160,7 +162,7 @@ pub async fn publish_config( ) -> Result, AppError> { dbx_core::nacos::service::nacos_publish_config_core(&state.app, &req.connection_id, req.req) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -170,7 +172,7 @@ pub async fn delete_config( ) -> Result, AppError> { dbx_core::nacos::service::nacos_delete_config_core(&state.app, &req.connection_id, req.key) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -180,7 +182,7 @@ pub async fn list_config_history( ) -> Result, AppError> { let result = dbx_core::nacos::service::nacos_list_config_history_core(&state.app, &req.connection_id, req.query) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -190,7 +192,7 @@ pub async fn get_config_history( ) -> Result, AppError> { let result = dbx_core::nacos::service::nacos_get_config_history_core(&state.app, &req.connection_id, req.key) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -200,7 +202,7 @@ pub async fn rollback_config( ) -> Result, AppError> { dbx_core::nacos::service::nacos_rollback_config_core(&state.app, &req.connection_id, req.req) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -210,7 +212,7 @@ pub async fn list_services( ) -> Result, AppError> { let result = dbx_core::nacos::service::nacos_list_services_core(&state.app, &req.connection_id, req.query) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -220,7 +222,7 @@ pub async fn list_instances( ) -> Result>, AppError> { let result = dbx_core::nacos::service::nacos_list_instances_core(&state.app, &req.connection_id, req.query) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -230,7 +232,7 @@ pub async fn update_instance( ) -> Result, AppError> { dbx_core::nacos::service::nacos_update_instance_core(&state.app, &req.connection_id, req.req) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -240,6 +242,6 @@ pub async fn raw_request( ) -> Result, AppError> { let result = dbx_core::nacos::service::nacos_raw_request_core(&state.app, &req.connection_id, req.req) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } diff --git a/crates/dbx-web/src/routes/plugins.rs b/crates/dbx-web/src/routes/plugins.rs index e7b8c5a456..ed1c9a3329 100644 --- a/crates/dbx-web/src/routes/plugins.rs +++ b/crates/dbx-web/src/routes/plugins.rs @@ -8,5 +8,5 @@ use crate::error::AppError; use crate::state::WebState; pub async fn list_plugins(State(state): State>) -> Result>, AppError> { - state.app.plugins.list_installed().map(Json).map_err(AppError) + state.app.plugins.list_installed().map(Json).map_err(AppError::from) } diff --git a/crates/dbx-web/src/routes/prompt_template.rs b/crates/dbx-web/src/routes/prompt_template.rs new file mode 100644 index 0000000000..41c48489e6 --- /dev/null +++ b/crates/dbx-web/src/routes/prompt_template.rs @@ -0,0 +1,97 @@ +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::Json; +use serde::{Deserialize, Serialize}; + +use dbx_core::prompt_template::PromptTemplate; + +use crate::error::AppError; +use crate::state::WebState; + +// --------------------------------------------------------------------------- +// Request types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavePromptTemplateRequest { + pub id: String, + pub name: String, + pub content: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetGlobalInstructionsRequest { + pub content: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetGlobalInstructionsResponse { + pub content: String, +} + +// --------------------------------------------------------------------------- +// Prompt Templates CRUD +// --------------------------------------------------------------------------- + +pub async fn load_prompt_templates(State(state): State>) -> Result>, AppError> { + let templates = state.app.storage.load_prompt_templates().await.map_err(AppError::from)?; + Ok(Json(templates)) +} + +pub async fn save_prompt_template( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + let result = state.app.storage.save_prompt_template(&body.id, &body.name, &body.content).await.map_err(|e| { + // Map validation errors to 400 + if e.contains("too long") || e.contains("cannot be empty") || e.contains("duplicate") { + AppError::bad_request(e) + } else { + AppError::from(e) + } + })?; + Ok(Json(result)) +} + +pub async fn delete_prompt_template( + State(state): State>, + Path(id): Path, +) -> Result, AppError> { + state.app.storage.delete_prompt_template(&id).await.map_err(|e| { + if e.contains("not found") { + AppError::not_found(e) + } else { + AppError::from(e) + } + })?; + Ok(Json(())) +} + +// --------------------------------------------------------------------------- +// Global Custom Instructions +// --------------------------------------------------------------------------- + +pub async fn get_global_instructions( + State(state): State>, +) -> Result, AppError> { + let content = state.app.storage.load_ai_global_custom_instructions().await.map_err(AppError::from)?; + Ok(Json(GetGlobalInstructionsResponse { content })) +} + +pub async fn set_global_instructions( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + state.app.storage.save_ai_global_custom_instructions(&body.content).await.map_err(|e| { + if e.contains("too long") { + AppError::bad_request(e) + } else { + AppError::from(e) + } + })?; + Ok(Json(())) +} diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index f879318761..ce9c3c3336 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -353,7 +353,7 @@ pub async fn execute_query( }, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; drop(registered); Ok(Json(result)) @@ -396,7 +396,7 @@ pub async fn execute_multi( }, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; drop(registered); Ok(Json(result)) @@ -420,7 +420,7 @@ pub async fn execute_batch( req.timeout_secs, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -445,7 +445,7 @@ pub async fn close_query_session( req.client_session_id.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!(closed))) } @@ -459,7 +459,7 @@ pub async fn close_client_connection_session( .app .close_client_session_pool(&req.connection_id, database, &req.client_session_id) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!(closed))) } @@ -485,7 +485,7 @@ pub async fn execute_script( None, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -503,7 +503,7 @@ pub async fn execute_in_transaction( req.schema.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -511,7 +511,7 @@ pub async fn execute_in_transaction( pub async fn analyze_sql_references( Json(req): Json, ) -> Result, AppError> { - dbx_core::sql_analysis::analyze_sql_references(&req.sql, req.dialect.as_deref()).map(Json).map_err(AppError) + dbx_core::sql_analysis::analyze_sql_references(&req.sql, req.dialect.as_deref()).map(Json).map_err(AppError::from) } pub async fn find_statement_at_cursor(Json(req): Json) -> Json { @@ -571,7 +571,7 @@ pub async fn get_explain_info( req.mode.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(plan)) } @@ -600,13 +600,13 @@ pub async fn build_search_result_where(Json(req): Json) -> Result, AppError> { - dbx_core::db_admin_sql::build_rename_object_sql(req.options).map(Json).map_err(AppError) + dbx_core::db_admin_sql::build_rename_object_sql(req.options).map(Json).map_err(AppError::from) } pub async fn build_create_database_sql( Json(req): Json, ) -> Result, AppError> { - dbx_core::db_admin_sql::build_create_database_sql(req.options).map(Json).map_err(AppError) + dbx_core::db_admin_sql::build_create_database_sql(req.options).map(Json).map_err(AppError::from) } #[cfg(feature = "duckdb-bundled")] @@ -629,7 +629,7 @@ pub async fn build_drop_table_sql(Json(req): Json) -> pub async fn build_drop_table_child_object_sql( Json(req): Json, ) -> Result, AppError> { - dbx_core::db_admin_sql::build_drop_table_child_object_sql(req.options).map(Json).map_err(AppError) + dbx_core::db_admin_sql::build_drop_table_child_object_sql(req.options).map(Json).map_err(AppError::from) } pub async fn build_empty_table_sql(Json(req): Json) -> Json { @@ -645,13 +645,13 @@ pub async fn build_drop_database_sql(Json(req): Json) -> Result, AppError> { - dbx_core::db_admin_sql::build_create_schema_sql(req.options).map(Json).map_err(AppError) + dbx_core::db_admin_sql::build_create_schema_sql(req.options).map(Json).map_err(AppError::from) } pub async fn build_update_database_properties_sql( Json(req): Json, ) -> Result, AppError> { - dbx_core::db_admin_sql::build_update_database_properties_sql(req.options).map(Json).map_err(AppError) + dbx_core::db_admin_sql::build_update_database_properties_sql(req.options).map(Json).map_err(AppError::from) } pub async fn build_drop_schema_sql(Json(req): Json) -> Json { @@ -671,13 +671,13 @@ pub async fn build_copy_table_data_sql(Json(req): Json, ) -> Result>, AppError> { - dbx_core::object_source_sql::build_executable_object_source_statements(req.input).map(Json).map_err(AppError) + dbx_core::object_source_sql::build_executable_object_source_statements(req.input).map(Json).map_err(AppError::from) } pub async fn build_executable_object_source_sql( Json(req): Json, ) -> Result, AppError> { - dbx_core::object_source_sql::build_executable_object_source_sql(req.input).map(Json).map_err(AppError) + dbx_core::object_source_sql::build_executable_object_source_sql(req.input).map(Json).map_err(AppError::from) } pub async fn build_editable_object_source(Json(req): Json) -> Json { @@ -687,7 +687,9 @@ pub async fn build_editable_object_source(Json(req): Json, ) -> Result>, AppError> { - dbx_core::object_source_sql::build_routine_rename_object_source_statements(req.input).map(Json).map_err(AppError) + dbx_core::object_source_sql::build_routine_rename_object_source_statements(req.input) + .map(Json) + .map_err(AppError::from) } pub async fn build_view_ddl_sql(Json(req): Json) -> Json { @@ -712,7 +714,7 @@ pub async fn preview_sqlite_table_structure_change( ) .await .map(Json) - .map_err(AppError) + .map_err(AppError::from) } pub async fn apply_sqlite_table_structure_change( @@ -728,7 +730,7 @@ pub async fn apply_sqlite_table_structure_change( ) .await .map(Json) - .map_err(AppError) + .map_err(AppError::from) } pub async fn build_create_table_sql( @@ -802,11 +804,11 @@ pub async fn build_hive_table_properties_sql(Json(req): Json, ) -> Result>, AppError> { - dbx_core::database_export::build_export_insert_statements(req.options).map(Json).map_err(AppError) + dbx_core::database_export::build_export_insert_statements(req.options).map(Json).map_err(AppError::from) } pub async fn build_export_sql_insert(Json(req): Json) -> Result, AppError> { - dbx_core::database_export::build_export_sql_insert(req.options).map(Json).map_err(AppError) + dbx_core::database_export::build_export_sql_insert(req.options).map(Json).map_err(AppError::from) } pub async fn build_database_sql_export( @@ -841,5 +843,5 @@ pub async fn build_database_sql_export( } } } - dbx_core::database_export::build_database_sql_export(options).map(Json).map_err(AppError) + dbx_core::database_export::build_database_sql_export(options).map(Json).map_err(AppError::from) } diff --git a/crates/dbx-web/src/routes/query_result_export.rs b/crates/dbx-web/src/routes/query_result_export.rs index 557977949d..f74b72449e 100644 --- a/crates/dbx-web/src/routes/query_result_export.rs +++ b/crates/dbx-web/src/routes/query_result_export.rs @@ -43,10 +43,10 @@ pub async fn start_query_result_export( let ext = match req.format.as_str() { "csv" => "csv", "xlsx" => "xlsx", - _ => return Err(AppError(format!("Unsupported query result export format: {}", req.format))), + _ => return Err(AppError::from(format!("Unsupported query result export format: {}", req.format))), }; let tmp_dir = state.data_dir.join("tmp"); - std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?; + std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?; let tmp_file = tmp_dir.join(format!("query_result_export_{export_id}.{ext}")); let file_path = tmp_file.to_string_lossy().to_string(); req.file_path = file_path.clone(); @@ -146,15 +146,15 @@ pub async fn query_result_export_download( .write() .await .remove(&export_id) - .ok_or_else(|| AppError("Export file not found".to_string()))?; + .ok_or_else(|| AppError::from("Export file not found".to_string()))?; - let data = tokio::fs::read(&file_path).await.map_err(|e| AppError(e.to_string()))?; + let data = tokio::fs::read(&file_path).await.map_err(|e| AppError::from(e.to_string()))?; let _ = tokio::fs::remove_file(&file_path).await; let (content_type, file_ext) = match format.as_str() { "csv" => ("text/csv; charset=utf-8", "csv"), "xlsx" => ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"), - _ => return Err(AppError(format!("Unknown format: {format}"))), + _ => return Err(AppError::from(format!("Unknown format: {format}"))), }; let filename = format!("query_result_export_{export_id}.{file_ext}"); @@ -164,5 +164,5 @@ pub async fn query_result_export_download( .header(header::CONTENT_TYPE, content_type) .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\"")) .body(Body::from(data)) - .map_err(|e| AppError(e.to_string())) + .map_err(|e| AppError::from(e.to_string())) } diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index b9d8c6f991..a66b741c8a 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -15,7 +15,7 @@ async fn ensure_writable( action: &str, ) -> Result<(), AppError> { if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await { - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", name, action ))); @@ -215,8 +215,8 @@ pub async fn list_databases( Json(req): Json, ) -> Result, AppError> { let result = - dbx_core::redis_ops::redis_list_databases_core(&state.app, &req.connection_id).await.map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + dbx_core::redis_ops::redis_list_databases_core(&state.app, &req.connection_id).await.map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn scan_keys( @@ -232,8 +232,8 @@ pub async fn scan_keys( req.count, ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn scan_keys_batch( @@ -251,8 +251,8 @@ pub async fn scan_keys_batch( req.include_types.unwrap_or(true), ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn scan_values( @@ -270,8 +270,8 @@ pub async fn scan_values( req.count, ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn get_value( @@ -280,8 +280,8 @@ pub async fn get_value( ) -> Result, AppError> { let result = dbx_core::redis_ops::redis_get_value_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn load_more( @@ -299,8 +299,8 @@ pub async fn load_more( req.filter.as_deref(), ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn set_string( @@ -317,7 +317,7 @@ pub async fn set_string( req.ttl, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -328,7 +328,7 @@ pub async fn delete_key( ensure_writable(&state.app, &req.connection_id, "Delete key").await?; dbx_core::redis_ops::redis_delete_key_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -348,7 +348,7 @@ pub async fn hash_set( req.ttl, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -359,7 +359,7 @@ pub async fn hash_del( ensure_writable(&state.app, &req.connection_id, "HDEL").await?; dbx_core::redis_ops::redis_hash_del_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.field) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -378,7 +378,7 @@ pub async fn list_push( req.ttl, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -391,7 +391,7 @@ pub async fn list_set( let value = req.value.as_deref().unwrap_or(""); dbx_core::redis_ops::redis_list_set_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, index, value) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -403,7 +403,7 @@ pub async fn list_remove( let index = req.index.unwrap_or(0); dbx_core::redis_ops::redis_list_remove_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, index) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -421,7 +421,7 @@ pub async fn set_add( req.ttl, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -432,7 +432,7 @@ pub async fn set_remove( ensure_writable(&state.app, &req.connection_id, "SREM").await?; dbx_core::redis_ops::redis_set_remove_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.member) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(())) } @@ -447,7 +447,7 @@ pub async fn zadd(State(state): State>, Json(req): Json Result, AppError> { let result = dbx_core::redis_ops::redis_check_json_module_in_db_core(&state.app, &req.connection_id, req.db) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -504,7 +504,7 @@ pub async fn delete_keys( let result = dbx_core::redis_ops::redis_delete_keys_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raws) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -513,7 +513,7 @@ pub async fn flush_db( Json(req): Json, ) -> Result, AppError> { ensure_writable(&state.app, &req.connection_id, "FLUSHDB").await?; - dbx_core::redis_ops::redis_flush_db_core(&state.app, &req.connection_id, req.db).await.map_err(AppError)?; + dbx_core::redis_ops::redis_flush_db_core(&state.app, &req.connection_id, req.db).await.map_err(AppError::from)?; Ok(Json(())) } @@ -524,7 +524,7 @@ pub async fn execute_command( ) -> Result, AppError> { super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?; let argv = dbx_core::db::redis_driver::parse_command_argv(&req.command) - .map_err(|error| AppError(format!("Invalid Redis command: {error}")))?; + .map_err(|error| AppError::from(format!("Invalid Redis command: {error}")))?; let cmd_name = argv[0].to_ascii_uppercase(); let safety = dbx_core::db::redis_driver::classify_command(&cmd_name); let is_mcp_request = super::mcp_policy::is_mcp_request(&headers); @@ -554,7 +554,7 @@ pub async fn execute_command( // In read-only mode, only allow safe read commands if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await { if safety != dbx_core::db::redis_driver::RedisCommandSafety::Allowed { - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.", name, cmd_name ))); @@ -568,8 +568,8 @@ pub async fn execute_command( if is_mcp_request { centrally_approved_high_risk } else { req.skip_safety_check.unwrap_or(false) }, ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn publish_message( @@ -580,7 +580,7 @@ pub async fn publish_message( let count = dbx_core::redis_ops::redis_publish_core(&state.app, &req.connection_id, req.db, &req.channel, &req.message) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(serde_json::json!({ "subscribers": count }))) } @@ -596,15 +596,16 @@ pub async fn slowlog_get( req.node_port, ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn cluster_master_nodes( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let result = - dbx_core::redis_ops::redis_cluster_master_nodes_core(&state.app, &req.connection_id).await.map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + let result = dbx_core::redis_ops::redis_cluster_master_nodes_core(&state.app, &req.connection_id) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } diff --git a/crates/dbx-web/src/routes/saved_sql.rs b/crates/dbx-web/src/routes/saved_sql.rs index 5ebb8a0cec..62d72742ec 100644 --- a/crates/dbx-web/src/routes/saved_sql.rs +++ b/crates/dbx-web/src/routes/saved_sql.rs @@ -8,7 +8,7 @@ use crate::error::AppError; use crate::state::WebState; pub async fn load_saved_sql_library(State(state): State>) -> Result, AppError> { - let library = state.app.storage.load_saved_sql_library_summary().await.map_err(AppError)?; + let library = state.app.storage.load_saved_sql_library_summary().await.map_err(AppError::from)?; Ok(Json(library)) } @@ -16,7 +16,7 @@ pub async fn load_saved_sql_file( State(state): State>, Path(id): Path, ) -> Result>, AppError> { - let file = state.app.storage.load_saved_sql_file(&id).await.map_err(AppError)?; + let file = state.app.storage.load_saved_sql_file(&id).await.map_err(AppError::from)?; Ok(Json(file)) } @@ -24,7 +24,7 @@ pub async fn save_saved_sql_folder( State(state): State>, Json(folder): Json, ) -> Result, AppError> { - state.app.storage.save_saved_sql_folder(&folder).await.map_err(AppError)?; + state.app.storage.save_saved_sql_folder(&folder).await.map_err(AppError::from)?; Ok(Json(folder)) } @@ -32,7 +32,7 @@ pub async fn delete_saved_sql_folder( State(state): State>, Path(id): Path, ) -> Result, AppError> { - state.app.storage.delete_saved_sql_folder(&id).await.map_err(AppError)?; + state.app.storage.delete_saved_sql_folder(&id).await.map_err(AppError::from)?; Ok(Json(())) } @@ -40,7 +40,7 @@ pub async fn save_saved_sql_file( State(state): State>, Json(file): Json, ) -> Result, AppError> { - state.app.storage.save_saved_sql_file(&file).await.map_err(AppError)?; + state.app.storage.save_saved_sql_file(&file).await.map_err(AppError::from)?; Ok(Json(file)) } @@ -48,6 +48,6 @@ pub async fn delete_saved_sql_file( State(state): State>, Path(id): Path, ) -> Result, AppError> { - state.app.storage.delete_saved_sql_file(&id).await.map_err(AppError)?; + state.app.storage.delete_saved_sql_file(&id).await.map_err(AppError::from)?; Ok(Json(())) } diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 491629a54a..dbb3068572 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -29,8 +29,8 @@ pub async fn list_databases( State(state): State>, Query(q): Query, ) -> Result, AppError> { - let result = dbx_core::schema::list_databases_core(&state.app, &q.connection_id).await.map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + let result = dbx_core::schema::list_databases_core(&state.app, &q.connection_id).await.map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } /// Resolve a non-internal catalog for dispatch to the Doris multi-catalog path. @@ -42,8 +42,9 @@ pub async fn list_doris_catalogs( State(state): State>, Query(q): Query, ) -> Result, AppError> { - let result = dbx_core::schema::list_doris_catalogs_core(&state.app, &q.connection_id).await.map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + let result = + dbx_core::schema::list_doris_catalogs_core(&state.app, &q.connection_id).await.map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_doris_catalog_databases( @@ -53,17 +54,18 @@ pub async fn list_doris_catalog_databases( let catalog = q.catalog.as_deref().unwrap_or("internal"); let result = dbx_core::schema::list_doris_catalog_databases_core(&state.app, &q.connection_id, catalog) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_sqlserver_linked_servers( State(state): State>, Query(q): Query, ) -> Result, AppError> { - let result = - dbx_core::schema::list_sqlserver_linked_servers_core(&state.app, &q.connection_id).await.map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + let result = dbx_core::schema::list_sqlserver_linked_servers_core(&state.app, &q.connection_id) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_sqlserver_linked_server_catalogs( @@ -73,8 +75,8 @@ pub async fn list_sqlserver_linked_server_catalogs( let server = q.server.as_deref().unwrap_or(""); let result = dbx_core::schema::list_sqlserver_linked_server_catalogs_core(&state.app, &q.connection_id, server) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_sqlserver_linked_server_schemas( @@ -86,7 +88,7 @@ pub async fn list_sqlserver_linked_server_schemas( let result = dbx_core::schema::list_sqlserver_linked_server_schemas_core(&state.app, &q.connection_id, server, catalog) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -108,8 +110,8 @@ pub async fn list_sqlserver_linked_server_tables( q.offset, ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn get_sqlserver_column_metadata( @@ -122,8 +124,8 @@ pub async fn get_sqlserver_column_metadata( let result = dbx_core::schema::get_sqlserver_column_metadata_core(&state.app, &q.connection_id, database, schema, table) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_schemas( @@ -138,7 +140,7 @@ pub async fn list_schemas( q.apply_visible_filter.unwrap_or(false), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -163,7 +165,7 @@ pub async fn list_tables( object_types.as_deref(), ) .await - .map_err(AppError)? + .map_err(AppError::from)? } else { dbx_core::schema::list_tables_core( &state.app, @@ -176,9 +178,9 @@ pub async fn list_tables( object_types.as_deref(), ) .await - .map_err(AppError)? + .map_err(AppError::from)? }; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_objects( @@ -202,7 +204,7 @@ pub async fn list_objects( object_types.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; tables .into_iter() .map(|table| dbx_core::db::ObjectInfo { @@ -230,9 +232,9 @@ pub async fn list_objects( object_types.as_deref(), ) .await - .map_err(AppError)? + .map_err(AppError::from)? }; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_object_statistics( @@ -243,8 +245,8 @@ pub async fn list_object_statistics( let schema = q.schema.as_deref().unwrap_or(""); let result = dbx_core::schema::list_object_statistics_core(&state.app, &q.connection_id, database, schema) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_completion_objects( @@ -255,15 +257,16 @@ pub async fn list_completion_objects( let schema = q.schema.as_deref().unwrap_or(""); let result = dbx_core::schema::list_completion_objects_core(&state.app, &q.connection_id, database, schema) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn completion_assistant_search( State(state): State>, Json(request): Json, ) -> Result, AppError> { - let result = dbx_core::schema::completion_assistant_search_core(&state.app, request).await.map_err(AppError)?; + let result = + dbx_core::schema::completion_assistant_search_core(&state.app, request).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -274,7 +277,7 @@ pub async fn get_object_source( let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); let name = q.table.as_deref().unwrap_or(""); - let object_type = q.object_type.ok_or_else(|| AppError("Missing object_type".to_string()))?; + let object_type = q.object_type.ok_or_else(|| AppError::from("Missing object_type".to_string()))?; let result = dbx_core::schema::get_object_source_core( &state.app, &q.connection_id, @@ -285,7 +288,7 @@ pub async fn get_object_source( q.signature.as_deref(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -299,7 +302,7 @@ pub async fn list_columns( let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { dbx_core::schema::get_doris_catalog_columns_core(&state.app, &q.connection_id, &catalog, database, table) .await - .map_err(AppError)? + .map_err(AppError::from)? } else { dbx_core::schema::get_columns_core_for_session( &state.app, @@ -310,9 +313,9 @@ pub async fn list_columns( q.client_session_id.as_deref(), ) .await - .map_err(AppError)? + .map_err(AppError::from)? }; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_data_types( @@ -321,8 +324,8 @@ pub async fn list_data_types( ) -> Result, AppError> { let database = q.database.as_deref().unwrap_or(""); let result = - dbx_core::schema::list_data_types_core(&state.app, &q.connection_id, database).await.map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + dbx_core::schema::list_data_types_core(&state.app, &q.connection_id, database).await.map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_indexes( @@ -335,13 +338,13 @@ pub async fn list_indexes( let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { dbx_core::schema::list_doris_catalog_indexes_core(&state.app, &q.connection_id, &catalog, database, table) .await - .map_err(AppError)? + .map_err(AppError::from)? } else { dbx_core::schema::list_indexes_core(&state.app, &q.connection_id, database, schema, table) .await - .map_err(AppError)? + .map_err(AppError::from)? }; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_foreign_keys( @@ -354,13 +357,13 @@ pub async fn list_foreign_keys( let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { dbx_core::schema::list_doris_catalog_foreign_keys_core(&state.app, &q.connection_id, &catalog, database, table) .await - .map_err(AppError)? + .map_err(AppError::from)? } else { dbx_core::schema::list_foreign_keys_core(&state.app, &q.connection_id, database, schema, table) .await - .map_err(AppError)? + .map_err(AppError::from)? }; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_triggers( @@ -373,13 +376,13 @@ pub async fn list_triggers( let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { dbx_core::schema::list_doris_catalog_triggers_core(&state.app, &q.connection_id, &catalog, database, table) .await - .map_err(AppError)? + .map_err(AppError::from)? } else { dbx_core::schema::list_triggers_core(&state.app, &q.connection_id, database, schema, table) .await - .map_err(AppError)? + .map_err(AppError::from)? }; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn get_ddl( @@ -392,11 +395,11 @@ pub async fn get_ddl( let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { dbx_core::schema::get_doris_catalog_table_ddl_core(&state.app, &q.connection_id, &catalog, database, table) .await - .map_err(AppError)? + .map_err(AppError::from)? } else { dbx_core::schema::get_table_ddl_core(&state.app, &q.connection_id, database, schema, table, q.object_type) .await - .map_err(AppError)? + .map_err(AppError::from)? }; Ok(Json(result)) } @@ -409,8 +412,8 @@ pub async fn list_functions( let schema = q.schema.as_deref().unwrap_or(""); let result = dbx_core::schema::list_functions_core(&state.app, &q.connection_id, database, schema) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } #[derive(Deserialize)] @@ -435,8 +438,8 @@ pub async fn list_sequences( q.with_last_values.unwrap_or(false), ) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_rules( @@ -445,9 +448,10 @@ pub async fn list_rules( ) -> Result, AppError> { let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); - let result = - dbx_core::schema::list_rules_core(&state.app, &q.connection_id, database, schema).await.map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + let result = dbx_core::schema::list_rules_core(&state.app, &q.connection_id, database, schema) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_owners( @@ -456,9 +460,10 @@ pub async fn list_owners( ) -> Result, AppError> { let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); - let result = - dbx_core::schema::list_owners_core(&state.app, &q.connection_id, database, schema).await.map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + let result = dbx_core::schema::list_owners_core(&state.app, &q.connection_id, database, schema) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_extensions( @@ -469,8 +474,8 @@ pub async fn list_extensions( let schema = q.schema.as_deref().unwrap_or(""); let result = dbx_core::schema::list_extensions_core(&state.app, &q.connection_id, database, schema) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } pub async fn list_available_extensions( @@ -480,6 +485,6 @@ pub async fn list_available_extensions( let database = q.database.as_deref().unwrap_or(""); let result = dbx_core::schema::list_available_extensions_core(&state.app, &q.connection_id, database) .await - .map_err(AppError)?; - Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } diff --git a/crates/dbx-web/src/routes/schema_cache.rs b/crates/dbx-web/src/routes/schema_cache.rs index 829df00ffe..7b81a7a227 100644 --- a/crates/dbx-web/src/routes/schema_cache.rs +++ b/crates/dbx-web/src/routes/schema_cache.rs @@ -28,7 +28,7 @@ pub async fn save_schema_cache( State(state): State>, Json(body): Json, ) -> Result, AppError> { - state.app.storage.save_schema_cache(&body.cache_key, &body.payload).await.map_err(AppError)?; + state.app.storage.save_schema_cache(&body.cache_key, &body.payload).await.map_err(AppError::from)?; Ok(Json(())) } @@ -36,7 +36,7 @@ pub async fn load_schema_cache( State(state): State>, Query(query): Query, ) -> Result, AppError> { - let payload = state.app.storage.load_schema_cache(&query.cache_key).await.map_err(AppError)?; + let payload = state.app.storage.load_schema_cache(&query.cache_key).await.map_err(AppError::from)?; Ok(Json(payload.unwrap_or(serde_json::json!(null)))) } @@ -44,6 +44,6 @@ pub async fn delete_schema_cache_prefix( State(state): State>, Query(query): Query, ) -> Result, AppError> { - state.app.storage.delete_schema_cache_prefix(&query.prefix).await.map_err(AppError)?; + state.app.storage.delete_schema_cache_prefix(&query.prefix).await.map_err(AppError::from)?; Ok(Json(())) } diff --git a/crates/dbx-web/src/routes/sql_file.rs b/crates/dbx-web/src/routes/sql_file.rs index e80aaa6065..1b27c29a7f 100644 --- a/crates/dbx-web/src/routes/sql_file.rs +++ b/crates/dbx-web/src/routes/sql_file.rs @@ -35,17 +35,17 @@ pub async fn preview_sql_file( mut multipart: Multipart, ) -> Result, AppError> { let tmp_dir = state.data_dir.join("tmp"); - std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?; + std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?; - if let Some(field) = multipart.next_field().await.map_err(|e| AppError(e.to_string()))? { + if let Some(field) = multipart.next_field().await.map_err(|e| AppError::from(e.to_string()))? { let file_name = field.file_name().unwrap_or("upload.sql").to_string(); - let data = field.bytes().await.map_err(|e| AppError(e.to_string()))?; + let data = field.bytes().await.map_err(|e| AppError::from(e.to_string()))?; let file_path = safe_uploaded_sql_path(&tmp_dir, &file_name)?; - std::fs::write(&file_path, &data).map_err(|e| AppError(e.to_string()))?; + std::fs::write(&file_path, &data).map_err(|e| AppError::from(e.to_string()))?; let size_bytes = data.len() as u64; - let content = sql::decode_sql_file_bytes(&data).map_err(AppError)?; + let content = sql::decode_sql_file_bytes(&data).map_err(AppError::from)?; let preview: String = content.chars().take(20_000).collect(); return Ok(Json(serde_json::json!({ @@ -57,7 +57,7 @@ pub async fn preview_sql_file( }))); } - Err(AppError("No file uploaded".to_string())) + Err(AppError::from("No file uploaded".to_string())) } pub async fn execute_sql_file( @@ -68,7 +68,7 @@ pub async fn execute_sql_file( // Fast-fail: reject early if the connection is read-only (individual statements are also checked in do_execute) if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await { - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: connection '{}' has read-only protection enabled. SQL file execution blocked.", name ))); @@ -81,7 +81,7 @@ pub async fn execute_sql_file( { let mut executions = state.sql_file_executions.write().await; if executions.contains_key(&execution_id) { - return Err(AppError(format!("SQL file execution '{execution_id}' already exists"))); + return Err(AppError::from(format!("SQL file execution '{execution_id}' already exists"))); } executions.insert(execution_id.clone(), token.clone()); } @@ -162,7 +162,7 @@ async fn cleanup_sql_file_execution(state: &WebState, execution_id: &str) { fn safe_uploaded_sql_path(tmp_dir: &Path, file_name: &str) -> Result { let base_name = file_name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or("upload.sql").trim(); if base_name.is_empty() || base_name == "." || base_name == ".." { - return Err(AppError("Invalid SQL file name".to_string())); + return Err(AppError::from("Invalid SQL file name".to_string())); } Ok(tmp_dir.join(base_name)) } @@ -170,13 +170,13 @@ fn safe_uploaded_sql_path(tmp_dir: &Path, file_name: &str) -> Result Result { let path = PathBuf::from(file_path); if !path.is_absolute() { - return Err(AppError("File path must be absolute".to_string())); + return Err(AppError::from("File path must be absolute".to_string())); } - let tmp_dir = data_dir.join("tmp").canonicalize().map_err(|e| AppError(e.to_string()))?; - let canonical_path = path.canonicalize().map_err(|e| AppError(e.to_string()))?; + let tmp_dir = data_dir.join("tmp").canonicalize().map_err(|e| AppError::from(e.to_string()))?; + let canonical_path = path.canonicalize().map_err(|e| AppError::from(e.to_string()))?; if !canonical_path.starts_with(&tmp_dir) { - return Err(AppError("File path must be inside the uploaded SQL directory".to_string())); + return Err(AppError::from("File path must be inside the uploaded SQL directory".to_string())); } Ok(canonical_path) } @@ -186,7 +186,7 @@ pub async fn sql_file_progress( AxumPath(execution_id): AxumPath, ) -> Result>>, AppError> { let channels = state.sse_channels.read().await; - let tx = channels.get(&execution_id).ok_or_else(|| AppError("Execution not found".to_string()))?; + let tx = channels.get(&execution_id).ok_or_else(|| AppError::from("Execution not found".to_string()))?; let rx = tx.subscribe(); drop(channels); Ok(crate::sse::sse_from_lossy_channel(rx)) @@ -216,7 +216,7 @@ mod tests { let path = match safe_uploaded_sql_path(&tmp_dir, "../outside.sql") { Ok(path) => path, - Err(error) => panic!("{}", error.0), + Err(error) => panic!("{}", error.message), }; assert_eq!(path, tmp_dir.join("outside.sql")); diff --git a/crates/dbx-web/src/routes/table_export.rs b/crates/dbx-web/src/routes/table_export.rs index 3949e45897..d19291a46d 100644 --- a/crates/dbx-web/src/routes/table_export.rs +++ b/crates/dbx-web/src/routes/table_export.rs @@ -39,14 +39,14 @@ pub async fn start_table_export( // Generate temp file path for web export output let tmp_dir = state.data_dir.join("tmp"); - std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?; + std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?; let ext = match req.format.as_str() { "csv" => "csv", "xlsx" => "xlsx", "json" => "json", "markdown" | "md" => "md", "sql" => "sql", - _ => return Err(AppError(format!("Unsupported export format: {}", req.format))), + _ => return Err(AppError::from(format!("Unsupported export format: {}", req.format))), }; let tmp_file = tmp_dir.join(format!("table_export_{export_id}.{ext}")); let file_path = tmp_file.to_string_lossy().to_string(); @@ -132,9 +132,9 @@ pub async fn table_export_download( .write() .await .remove(&export_id) - .ok_or_else(|| AppError("Export file not found".to_string()))?; + .ok_or_else(|| AppError::from("Export file not found".to_string()))?; - let data = tokio::fs::read(&file_path).await.map_err(|e| AppError(e.to_string()))?; + let data = tokio::fs::read(&file_path).await.map_err(|e| AppError::from(e.to_string()))?; // Clean up temp file let _ = tokio::fs::remove_file(&file_path).await; @@ -144,7 +144,7 @@ pub async fn table_export_download( "json" => ("application/json; charset=utf-8", "json"), "markdown" | "md" => ("text/markdown; charset=utf-8", "md"), "sql" => ("application/sql; charset=utf-8", "sql"), - _ => return Err(AppError(format!("Unknown format: {format}"))), + _ => return Err(AppError::from(format!("Unknown format: {format}"))), }; let filename = format!("table_export_{export_id}.{file_ext}"); @@ -154,5 +154,5 @@ pub async fn table_export_download( .header(header::CONTENT_TYPE, content_type) .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\"")) .body(Body::from(data)) - .map_err(|e| AppError(e.to_string())) + .map_err(|e| AppError::from(e.to_string())) } diff --git a/crates/dbx-web/src/routes/table_import.rs b/crates/dbx-web/src/routes/table_import.rs index bebe050f36..cabe026f2c 100644 --- a/crates/dbx-web/src/routes/table_import.rs +++ b/crates/dbx-web/src/routes/table_import.rs @@ -37,7 +37,7 @@ pub async fn preview_import( mut multipart: Multipart, ) -> Result, AppError> { let tmp_dir = import_upload_dir(&state.data_dir); - std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?; + std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?; cleanup_expired_import_uploads(&tmp_dir, Duration::from_secs(24 * 60 * 60)); let mut uploaded_file: Option<(String, PathBuf)> = None; @@ -51,14 +51,14 @@ pub async fn preview_import( Ok(None) => break, Err(error) => { cleanup_pending_upload(&uploaded_file).await; - return Err(AppError(error.to_string())); + return Err(AppError::from(error.to_string())); } }; let name = field.name().unwrap_or_default().to_string(); if name == "file" { if uploaded_file.is_some() { cleanup_pending_upload(&uploaded_file).await; - return Err(AppError("Only one import file may be uploaded".to_string())); + return Err(AppError::from("Only one import file may be uploaded".to_string())); } let file_name = field.file_name().unwrap_or("upload.csv").to_string(); let source_ref = uuid::Uuid::new_v4().to_string(); @@ -73,7 +73,7 @@ pub async fn preview_import( Ok(value) => value, Err(error) => { cleanup_pending_upload(&uploaded_file).await; - return Err(AppError(error.to_string())); + return Err(AppError::from(error.to_string())); } }; match name.as_str() { @@ -82,7 +82,7 @@ pub async fn preview_import( Ok(source_format) => Some(source_format), Err(error) => { cleanup_pending_upload(&uploaded_file).await; - return Err(AppError(error.to_string())); + return Err(AppError::from(error.to_string())); } }; } @@ -91,7 +91,7 @@ pub async fn preview_import( Ok(parse_options) => parse_options, Err(error) => { cleanup_pending_upload(&uploaded_file).await; - return Err(AppError(error.to_string())); + return Err(AppError::from(error.to_string())); } }; } @@ -117,20 +117,20 @@ pub async fn preview_import( Ok(preview) => preview, Err(error) => { cleanup_uploaded_import_path(&file_path).await; - return Err(AppError(error)); + return Err(AppError::from(error)); } }; let preview = match serde_json::to_value(preview) { Ok(preview) => preview, Err(error) => { cleanup_uploaded_import_path(&file_path).await; - return Err(AppError(error.to_string())); + return Err(AppError::from(error.to_string())); } }; return Ok(Json(preview)); } - Err(AppError("No file uploaded".to_string())) + Err(AppError::from("No file uploaded".to_string())) } async fn write_import_upload(field: axum::extract::multipart::Field<'_>, file_path: &StdPath) -> Result<(), AppError> { @@ -146,22 +146,22 @@ where S: Stream> + Unpin, E: std::fmt::Display, { - let mut upload = tokio::fs::File::create(file_path).await.map_err(|error| AppError(error.to_string()))?; + let mut upload = tokio::fs::File::create(file_path).await.map_err(|error| AppError::from(error.to_string()))?; let mut uploaded_bytes = 0usize; // Stream uploads to disk so valid large CSV files don't require a second full-size in-memory copy. let result = async { while let Some(chunk) = chunks.next().await { - let chunk = chunk.map_err(|error| AppError(error.to_string()))?; + let chunk = chunk.map_err(|error| AppError::from(error.to_string()))?; uploaded_bytes = uploaded_bytes.saturating_add(chunk.len()); if uploaded_bytes > max_upload_bytes { - return Err(AppError(format!( + return Err(AppError::from(format!( "File too large: {uploaded_bytes} bytes received (max {max_upload_bytes} bytes)" ))); } - upload.write_all(&chunk).await.map_err(|error| AppError(error.to_string()))?; + upload.write_all(&chunk).await.map_err(|error| AppError::from(error.to_string()))?; } - upload.flush().await.map_err(|error| AppError(error.to_string())) + upload.flush().await.map_err(|error| AppError::from(error.to_string())) } .await; drop(upload); @@ -183,7 +183,7 @@ pub async fn execute_import( // Reject import early if the connection is read-only if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await { cleanup_uploaded_import_source(&req.file_path).await; - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: connection '{}' has read-only protection enabled. Import blocked.", name ))); @@ -287,7 +287,7 @@ pub async fn import_progress( Path(import_id): Path, ) -> Result>>, AppError> { let channels = state.sse_channels.read().await; - let tx = channels.get(&import_id).ok_or_else(|| AppError("Import not found".to_string()))?; + let tx = channels.get(&import_id).ok_or_else(|| AppError::from("Import not found".to_string()))?; let rx = tx.subscribe(); drop(channels); Ok(crate::sse::sse_from_channel(rx)) @@ -308,7 +308,7 @@ fn import_upload_dir(data_dir: &StdPath) -> PathBuf { fn safe_uploaded_import_path(tmp_dir: &StdPath, file_name: &str, source_ref: &str) -> Result { let base_name = file_name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or("upload.csv").trim(); if base_name.is_empty() || base_name == "." || base_name == ".." { - return Err(AppError("Invalid import file name".to_string())); + return Err(AppError::from("Invalid import file name".to_string())); } Ok(tmp_dir.join(format!("{source_ref}-{base_name}"))) } @@ -316,14 +316,14 @@ fn safe_uploaded_import_path(tmp_dir: &StdPath, file_name: &str, source_ref: &st fn validated_uploaded_import_path(data_dir: &StdPath, file_path: &str) -> Result { let path = PathBuf::from(file_path); if !path.is_absolute() { - return Err(AppError("Import source path must be absolute".to_string())); + return Err(AppError::from("Import source path must be absolute".to_string())); } - let tmp_dir = import_upload_dir(data_dir).canonicalize().map_err(|e| AppError(e.to_string()))?; + let tmp_dir = import_upload_dir(data_dir).canonicalize().map_err(|e| AppError::from(e.to_string()))?; let canonical_path = - path.canonicalize().map_err(|e| AppError(format!("Import source is no longer available: {e}")))?; + path.canonicalize().map_err(|e| AppError::from(format!("Import source is no longer available: {e}")))?; if !canonical_path.starts_with(&tmp_dir) { - return Err(AppError("Import source must be inside the uploaded import directory".to_string())); + return Err(AppError::from("Import source must be inside the uploaded import directory".to_string())); } Ok(canonical_path) } @@ -387,7 +387,7 @@ mod tests { let error = write_import_upload_stream(chunks, &file_path, 4).await.unwrap_err(); - assert!(error.0.contains("File too large")); + assert!(error.message.contains("File too large")); assert!(!file_path.exists()); } @@ -398,7 +398,7 @@ mod tests { let error = write_import_upload_stream(chunks, &file_path, 8).await.unwrap_err(); - assert_eq!(error.0, "multipart stream failed"); + assert_eq!(error.message, "multipart stream failed"); assert!(!file_path.exists()); } } diff --git a/crates/dbx-web/src/routes/transfer.rs b/crates/dbx-web/src/routes/transfer.rs index 3f73fca471..2900bd65aa 100644 --- a/crates/dbx-web/src/routes/transfer.rs +++ b/crates/dbx-web/src/routes/transfer.rs @@ -33,11 +33,11 @@ pub async fn start_transfer( Json(body): Json, ) -> Result, AppError> { let req = body.request; - transfer::validate_transfer_target_table_names(&req).map_err(AppError)?; + transfer::validate_transfer_target_table_names(&req).map_err(AppError::from)?; // Reject transfer early if the target connection is read-only if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.target_connection_id).await { - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: target connection '{}' has read-only protection enabled. Transfer blocked.", name ))); @@ -353,13 +353,19 @@ pub async fn preview_transfer_ownership( Json(body): Json, ) -> Result, AppError> { let req = body.request; - transfer::validate_transfer_target_table_names(&req).map_err(AppError)?; - let source_db_type = transfer::get_db_type(&state.app, &req.source_connection_id).await.map_err(AppError)?; - let target_db_type = transfer::get_db_type(&state.app, &req.target_connection_id).await.map_err(AppError)?; - let source_pool_key = - state.app.get_or_create_pool(&req.source_connection_id, Some(&req.source_database)).await.map_err(AppError)?; - let target_pool_key = - state.app.get_or_create_pool(&req.target_connection_id, Some(&req.target_database)).await.map_err(AppError)?; + transfer::validate_transfer_target_table_names(&req).map_err(AppError::from)?; + let source_db_type = transfer::get_db_type(&state.app, &req.source_connection_id).await.map_err(AppError::from)?; + let target_db_type = transfer::get_db_type(&state.app, &req.target_connection_id).await.map_err(AppError::from)?; + let source_pool_key = state + .app + .get_or_create_pool(&req.source_connection_id, Some(&req.source_database)) + .await + .map_err(AppError::from)?; + let target_pool_key = state + .app + .get_or_create_pool(&req.target_connection_id, Some(&req.target_database)) + .await + .map_err(AppError::from)?; let preview = transfer::preview_transfer_ownership( &state.app, &req, @@ -369,7 +375,7 @@ pub async fn preview_transfer_ownership( &target_pool_key, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(preview)) } @@ -378,7 +384,7 @@ pub async fn transfer_progress( Path(transfer_id): Path, ) -> Result>>, AppError> { let channels = state.sse_channels.read().await; - let tx = channels.get(&transfer_id).ok_or_else(|| AppError("Transfer not found".to_string()))?; + let tx = channels.get(&transfer_id).ok_or_else(|| AppError::from("Transfer not found".to_string()))?; let rx = tx.subscribe(); drop(channels); Ok(crate::sse::sse_from_channel(rx)) @@ -416,5 +422,5 @@ pub async fn sort_tables_by_fk_dependency( ) .await .map(Json) - .map_err(AppError) + .map_err(AppError::from) } diff --git a/crates/dbx-web/src/routes/tunnel_profiles.rs b/crates/dbx-web/src/routes/tunnel_profiles.rs index 7f7ae031fe..0515b4f6e8 100644 --- a/crates/dbx-web/src/routes/tunnel_profiles.rs +++ b/crates/dbx-web/src/routes/tunnel_profiles.rs @@ -17,19 +17,19 @@ pub struct SaveTunnelProfilesRequest { pub async fn load_tunnel_profiles( State(state): State>, ) -> Result>, AppError> { - state.app.storage.load_tunnel_profiles().await.map(Json).map_err(AppError) + state.app.storage.load_tunnel_profiles().await.map(Json).map_err(AppError::from) } pub async fn save_tunnel_profiles( State(state): State>, Json(body): Json, ) -> Result, AppError> { - state.app.storage.save_tunnel_profiles(&body.profiles).await.map(Json).map_err(AppError) + state.app.storage.save_tunnel_profiles(&body.profiles).await.map(Json).map_err(AppError::from) } pub async fn test_tunnel_profile( State(state): State>, Json(profile): Json, ) -> Result, AppError> { - state.app.test_tunnel_profile(&profile).await.map(Json).map_err(AppError) + state.app.test_tunnel_profile(&profile).await.map(Json).map_err(AppError::from) } diff --git a/crates/dbx-web/src/routes/update.rs b/crates/dbx-web/src/routes/update.rs index 09e8fb0649..a267ba8b68 100644 --- a/crates/dbx-web/src/routes/update.rs +++ b/crates/dbx-web/src/routes/update.rs @@ -17,9 +17,10 @@ pub struct UpdateCheckParams { pub async fn check_for_updates(Query(params): Query) -> Result, AppError> { let locale = params.locale.unwrap_or_else(|| "zh-CN".to_string()); - let release = update::fetch_latest_release(&locale, params.source.unwrap_or_default()).await.map_err(AppError)?; + let release = + update::fetch_latest_release(&locale, params.source.unwrap_or_default()).await.map_err(AppError::from)?; let info = update::build_update_info(release, env!("CARGO_PKG_VERSION")); - Ok(Json(serde_json::to_value(info).map_err(|e| AppError(e.to_string()))?)) + Ok(Json(serde_json::to_value(info).map_err(|e| AppError::from(e.to_string()))?)) } #[derive(serde::Deserialize)] @@ -32,6 +33,6 @@ pub async fn fetch_changelog( Query(params): Query, ) -> Result, AppError> { let lang = params.lang.unwrap_or_else(|| "en".to_string()); - let data = changelog::fetch_changelog(&lang).await.map_err(AppError)?; + let data = changelog::fetch_changelog(&lang).await.map_err(AppError::from)?; Ok(Json(data)) } diff --git a/crates/dbx-web/src/routes/zookeeper.rs b/crates/dbx-web/src/routes/zookeeper.rs index bfac64dbed..b7335e3c32 100644 --- a/crates/dbx-web/src/routes/zookeeper.rs +++ b/crates/dbx-web/src/routes/zookeeper.rs @@ -13,7 +13,7 @@ async fn ensure_writable( action: &str, ) -> Result<(), AppError> { if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await { - return Err(AppError(format!( + return Err(AppError::from(format!( "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", name, action ))); @@ -60,7 +60,7 @@ pub async fn list_prefix( req.recursive, ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -68,7 +68,8 @@ pub async fn get( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let result = dbx_core::agent_kv::kv_get_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?; + let result = + dbx_core::agent_kv::kv_get_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -85,7 +86,7 @@ pub async fn put( req.options.unwrap_or_default(), ) .await - .map_err(AppError)?; + .map_err(AppError::from)?; Ok(Json(result)) } @@ -95,6 +96,6 @@ pub async fn delete( ) -> Result, AppError> { ensure_writable(&state.app, &req.connection_id, "Delete").await?; let result = - dbx_core::agent_kv::kv_delete_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?; + dbx_core::agent_kv::kv_delete_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError::from)?; Ok(Json(result)) } diff --git a/packages/app-tests/aiAssistantSendGuard.test.ts b/packages/app-tests/aiAssistantSendGuard.test.ts new file mode 100644 index 0000000000..c65d4ebe47 --- /dev/null +++ b/packages/app-tests/aiAssistantSendGuard.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import { parse } from "vue/compiler-sfc"; + +// Regression guard for the F2 finding: AiAssistant.send() must acquire the +// synchronous send guard (isGenerating.value = true) BEFORE its first await +// (promptTemplateStore.ensureLoaded()). Otherwise two rapid submissions can +// both pass the initial isGenerating check, await the deferred store load, and +// resume into two concurrent agent runs. +// +// send() is an internal