diff --git a/apps/desktop/src/components/meilisearch/MeilisearchSettingsPage.vue b/apps/desktop/src/components/meilisearch/MeilisearchSettingsPage.vue index 35ff72d654..789068b7ec 100644 --- a/apps/desktop/src/components/meilisearch/MeilisearchSettingsPage.vue +++ b/apps/desktop/src/components/meilisearch/MeilisearchSettingsPage.vue @@ -9,6 +9,7 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue"; import JsonTree from "@/components/common/JsonTree.vue"; import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue"; import * as api from "@/lib/backend/api"; +import type { MeilisearchIndexSettings } from "@/lib/backend/tauri"; import { useToast } from "@/composables/useToast"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -28,7 +29,7 @@ const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsText = ref(""); -const settingsValue = ref>({}); +const settingsValue = ref({}); const loading = ref(false); const error = ref(""); const editMode = ref(false); @@ -82,9 +83,9 @@ async function saveSettings() { isSaving.value = true; saveError.value = ""; try { - await api.meilisearchUpdateIndexSettings(props.connectionId, props.index, parsed as Record); + await api.meilisearchUpdateIndexSettings(props.connectionId, props.index, parsed as Record); toast(t("meilisearch.settingsSaved")); - settingsValue.value = parsed as Record; + settingsValue.value = parsed as MeilisearchIndexSettings; settingsText.value = JSON.stringify(parsed, null, 2); editMode.value = false; } catch (e: any) { diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index 8cca7f10d2..44b35797a5 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -383,6 +383,7 @@ function createBridge() { // bridge; the backend owns EXPLAIN generation, the timeout, and the plan cap. getPlanCapabilities: (connectionId) => api.getPluginPlanCapabilities(connectionId), explainPlan: (request) => api.getPluginEstimatedPlan(request), + getTableMetadata: (context) => api.getPluginTableMetadata(context), closeTab: () => emit("closeTab"), saveFile: (_pluginId, request, data) => savePluginFile(request, data), downloadFile: isTauriRuntime() ? downloadPluginFile : undefined, diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index b00c9dd4a4..936773e624 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -214,6 +214,7 @@ export const getObjectSource = forward("getObjectSource"); export const getEventInfo = forward("getEventInfo"); export const getCustomTypeDetails = forward("getCustomTypeDetails"); export const getColumns = forward("getColumns"); +export const getPluginTableMetadata = forward("getPluginTableMetadata"); export const getAllColumns = forward("getAllColumns"); export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata"); export const listDataTypes = forward("listDataTypes"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 89ae1f8b59..ab7097e610 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -190,10 +190,12 @@ import type { PromptTemplate, SshPromptResolution, MeilisearchIndexOverview, + MeilisearchIndexSettings, ElasticsearchIndexMetadataKind, ElasticsearchDeleteByQueryResult, } from "@/lib/backend/tauri"; import type { QueryEditability } from "@/lib/sql/sqlAnalysis"; +import type { PluginTableMetadata, PluginTableMetadataRequest } from "@/types/pluginSchemaMetadata"; import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode"; import type { MigrationPreflight, MigrationReport } from "./migration"; export type { MigrationPreflight, MigrationReport } from "./migration"; @@ -1166,6 +1168,10 @@ export async function getColumns(connectionId: string, database: string, schema: return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog, client_session_id: clientSessionId })}`); } +export async function getPluginTableMetadata(request: PluginTableMetadataRequest): Promise { + return post("/api/plugin/table-metadata", request); +} + export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise { return get(`/api/schema/sqlserver/column-metadata?${qs({ connection_id: connectionId, database, schema, table })}`); } @@ -4928,14 +4934,14 @@ export async function meilisearchGetDocument(connectionId: string, index: string }); } -export async function meilisearchGetIndexSettings(connectionId: string, index: string): Promise> { +export async function meilisearchGetIndexSettings(connectionId: string, index: string): Promise { return post("/api/document-store/meilisearch/settings/get", { connectionId, index, }); } -export async function meilisearchUpdateIndexSettings(connectionId: string, index: string, settings: Record): Promise { +export async function meilisearchUpdateIndexSettings(connectionId: string, index: string, settings: Record): Promise { return post("/api/document-store/meilisearch/settings/update", { connectionId, index, @@ -4943,7 +4949,7 @@ export async function meilisearchUpdateIndexSettings(connectionId: string, index }); } -export async function meilisearchGetIndexStats(connectionId: string, index: string): Promise<{ numberOfDocuments: number; isIndexing: boolean; fieldDistribution: Record } & Record> { +export async function meilisearchGetIndexStats(connectionId: string, index: string): Promise<{ numberOfDocuments: number; isIndexing: boolean; fieldDistribution: Record } & Record> { return post("/api/document-store/meilisearch/stats", { connectionId, index, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 0e1688db15..6ee68240de 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -24,6 +24,7 @@ import { collectBrowserSupportInfo } from "@/lib/app/supportInfo"; // Re-exported below so the HTTP transport shares one definition; imported here // for this module's own signatures (a re-export does not bind local names). import type { PluginPlanCapabilities, PluginPlanRequest, PluginPlanResult } from "@/types/pluginPlan"; +import type { PluginTableMetadata, PluginTableMetadataRequest } from "@/types/pluginSchemaMetadata"; function invoke(command: string, args?: Record): Promise { assertUpdateAllowsCommand(command); @@ -832,6 +833,7 @@ export async function saveMaxRetries(maxRetries: number): Promise { export type { OpenTabsStatePayload, PersistedEditorGroup } from "@/lib/app/openTabsPersistence"; /** Shared with `@/lib/plugins/pluginHostBridge`; re-exported so the HTTP transport reuses one definition. */ export type { PluginPlanCapabilities, PluginPlanRequest, PluginPlanResult } from "@/types/pluginPlan"; +export type { PluginColumnMetadata, PluginMetadataFieldAvailability, PluginMetadataFieldCapabilities, PluginTableMetadata, PluginTableMetadataRequest } from "@/types/pluginSchemaMetadata"; import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence"; import { uuid } from "@/lib/common/utils"; @@ -1540,6 +1542,10 @@ export async function getColumns(connectionId: string, database: string, schema: }); } +export async function getPluginTableMetadata(request: PluginTableMetadataRequest): Promise { + return invoke("get_plugin_table_metadata", { request }); +} + export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise { return invoke("get_sqlserver_column_metadata", { connectionId, @@ -4765,14 +4771,14 @@ export async function meilisearchGetDocument(connectionId: string, index: string }); } -export async function meilisearchGetIndexSettings(connectionId: string, index: string): Promise> { +export async function meilisearchGetIndexSettings(connectionId: string, index: string): Promise { return invoke("meilisearch_get_index_settings", { connectionId, index, }); } -export async function meilisearchUpdateIndexSettings(connectionId: string, index: string, settings: Record): Promise { +export async function meilisearchUpdateIndexSettings(connectionId: string, index: string, settings: Record): Promise { return invoke("meilisearch_update_index_settings", { connectionId, index, @@ -4780,13 +4786,20 @@ export async function meilisearchUpdateIndexSettings(connectionId: string, index }); } -export async function meilisearchGetIndexStats(connectionId: string, index: string): Promise<{ numberOfDocuments: number; isIndexing: boolean; fieldDistribution: Record } & Record> { +export async function meilisearchGetIndexStats(connectionId: string, index: string): Promise<{ numberOfDocuments: number; isIndexing: boolean; fieldDistribution: Record } & Record> { return invoke("meilisearch_get_index_stats", { connectionId, index, }); } +export interface MeilisearchIndexSettings { + [key: string]: unknown; + pagination?: { + maxTotalHits?: number; + }; +} + export interface MeilisearchIndexOverview { uid: string; primaryKey: string | null; diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index 092d6a7ae9..28718230f2 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -567,6 +567,95 @@ describe("PluginHostBridge", () => { expect(messages[2]).toMatchObject({ id: "legacy", error: "Host plan API is unavailable" }); }); + it("requires host.schema:read, normalizes table identity, and serves narrow metadata", async () => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const getTableMetadata = vi.fn().mockResolvedValue({ + columns: [{ name: "id", dataType: "integer", nullable: false }], + fieldCapabilities: { length: "unknown", precision: "unknown", scale: "unknown", default: "unknown" }, + }); + const base = { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn(), getTableMetadata }; + + const denied = new PluginHostBridge(plugin(), workbench, {}, () => target, base); + planCall(denied, target, "host.getTableMetadata", { connectionId: "c1", table: "users" }, "denied"); + await vi.waitFor(() => expect(messages).toHaveLength(1)); + expect(messages[0]).toMatchObject({ id: "denied", error: "Plugin has not declared permission 'host.schema:read'" }); + expect(getTableMetadata).not.toHaveBeenCalled(); + + const allowed = new PluginHostBridge(plugin(["host.schema:read"]), workbench, {}, () => target, base); + allowed.sendInit(); + planCall(allowed, target, "host.getTableMetadata", { connectionId: " c1 ", database: " app ", schema: " ", table: " users " }, "metadata"); + await vi.waitFor(() => expect(messages).toHaveLength(3)); + + expect(getTableMetadata).toHaveBeenCalledWith({ connectionId: "c1", database: "app", table: "users" }); + expect(messages[2]).toMatchObject({ id: "metadata", result: expect.objectContaining({ columns: expect.any(Array) }) }); + }); + + it("rejects malformed table metadata contexts and advertises the adapter capability", async () => { + const messages: any[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const getTableMetadata = vi.fn(); + const bridge = new PluginHostBridge(plugin(["host.schema:read"]), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + getTableMetadata, + }); + bridge.sendInit(); + expect(messages[0].capabilities).toMatchObject({ schemaMetadataApi: true }); + + const cases: [string, unknown, string][] = [ + ["missing-connection", { table: "users" }, "connectionId"], + ["blank-connection", { connectionId: " ", table: "users" }, "connectionId"], + ["missing-table", { connectionId: "c1" }, "table"], + ["table-type", { connectionId: "c1", table: 7 }, "table"], + ["identifier-long", { connectionId: "c1", table: "x".repeat(257) }, "table"], + ["scope-type", { connectionId: "c1", table: "users", schema: 1 }, "schema"], + ]; + for (const [id, params] of cases) planCall(bridge, target, "host.getTableMetadata", params, id); + planCall(bridge, target, "host.getTableMetadata", "not an object", "params-object"); + await vi.waitFor(() => expect(messages).toHaveLength(cases.length + 2)); + + cases.forEach(([id, , expected], index) => { + expect(messages[index + 1]).toMatchObject({ id }); + expect(messages[index + 1].error).toContain(expected); + }); + expect(messages[messages.length - 1]).toMatchObject({ id: "params-object", error: "host.getTableMetadata params must be an object" }); + expect(getTableMetadata).not.toHaveBeenCalled(); + + const legacyMessages: any[] = []; + const legacyTarget = { postMessage: (message: unknown) => legacyMessages.push(message) } as unknown as Window; + const legacy = new PluginHostBridge(plugin(["host.schema:read"]), workbench, {}, () => legacyTarget, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + }); + legacy.sendInit(); + expect(legacyMessages[0].capabilities).toMatchObject({ schemaMetadataApi: false }); + planCall(legacy, legacyTarget, "host.getTableMetadata", { connectionId: "c1", table: "users" }, "legacy"); + await vi.waitFor(() => expect(legacyMessages).toHaveLength(2)); + expect(legacyMessages[1]).toMatchObject({ id: "legacy", error: "Host schema metadata API is unavailable" }); + }); + + it.each(["Connection session is not open for the requested database", "Query canceled", "metadata provider denied", "DBX metadata pool is busy; please retry"])("forwards schema metadata failures without retrying: %s", async (error) => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const getTableMetadata = vi.fn().mockRejectedValue(new Error(error)); + const bridge = new PluginHostBridge(plugin(["host.schema:read"]), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + getTableMetadata, + }); + planCall(bridge, target, "host.getTableMetadata", { connectionId: "c1", table: "users" }, "failure"); + await vi.waitFor(() => expect(messages).toHaveLength(1)); + expect(messages[0]).toMatchObject({ id: "failure", error }); + expect(getTableMetadata).toHaveBeenCalledTimes(1); + }); + it("opens only the owning plugin filesystem with explicit permission", async () => { const messages: unknown[] = []; const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; @@ -1036,6 +1125,7 @@ describe("plugin SDK source", () => { invoke: (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise; getPlanCapabilities: (connectionId: string) => Promise; explainPlan: (request: unknown) => Promise; + getTableMetadata: (context: unknown) => Promise; }; } @@ -1123,6 +1213,17 @@ describe("plugin SDK source", () => { expect(requests[1].params).toEqual({ connectionId: "c1", sql: "SELECT 1", mode: "estimated" }); }); + it("exposes read-only table metadata through the SDK request surface", () => { + const posted: unknown[] = []; + const { dbxPlugin } = loadSdk(posted); + + void dbxPlugin!.getTableMetadata({ connectionId: "c1", database: "app", table: "users" }); + + const request = firstRequest(posted); + expect(request.method).toBe("host.getTableMetadata"); + expect(request.params).toEqual({ connectionId: "c1", database: "app", table: "users" }); + }); + it("forwards fileTransfer requests to the host api", async () => { const messages: unknown[] = []; const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index ef9e3af2e5..997526f6bf 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -2,6 +2,7 @@ import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayl import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData"; import { MAX_PLUGIN_PLAN_SQL_CHARS, MAX_PLUGIN_PLAN_TIMEOUT_MS, PLUGIN_PLAN_PERMISSION, type PluginPlanCapabilities, type PluginPlanRequest, type PluginPlanResult } from "@/types/pluginPlan"; import { createPluginAiConversation, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation"; +import { MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS, PLUGIN_SCHEMA_METADATA_CAPABILITY, PLUGIN_SCHEMA_METADATA_PERMISSION, type PluginTableContext, type PluginTableMetadata } from "@/types/pluginSchemaMetadata"; const PLUGIN_MESSAGE_SOURCE = "dbx-plugin"; const HOST_MESSAGE_SOURCE = "dbx-host"; @@ -109,6 +110,8 @@ export interface PluginHostBridgeApi { * generates and owns the EXPLAIN statement; the plugin cannot pass one. */ explainPlan?(request: PluginPlanRequest): Promise; + /** Read-only table schema metadata over an already-open Host connection. */ + getTableMetadata?(context: PluginTableContext): Promise; closeTab?(): Promise | void; /** Persist plugin bytes through the host's native save dialog. Resolves null when the user cancels. */ saveFile?(pluginId: string, request: PluginSaveFileRequest, data: Uint8Array): Promise; @@ -249,6 +252,7 @@ export class PluginHostBridge { capabilities: { downloadFile: !!this.api.downloadFile, planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan, + [PLUGIN_SCHEMA_METADATA_CAPABILITY]: !!this.api.getTableMetadata, storage: !!this.api.storageGet && !!this.api.storageSet && !!this.api.storageDelete, ai: !!this.api.openAiConversation, }, @@ -399,6 +403,11 @@ export class PluginHostBridge { if (!this.api.explainPlan) throw new Error("Host plan API is unavailable"); return this.api.explainPlan(requirePluginPlanRequest(requireRecord(params, "host.explainPlan params"))); } + if (method === "host.getTableMetadata") { + this.requirePermission(PLUGIN_SCHEMA_METADATA_PERMISSION); + if (!this.api.getTableMetadata) throw new Error("Host schema metadata API is unavailable"); + return this.api.getTableMetadata(requirePluginTableContext(requireRecord(params, "host.getTableMetadata params"))); + } if (method === "host.saveFile") { const input = isRecord(params) ? params : {}; // The sandboxed iframe cannot trigger downloads (WKWebView cancels blob @@ -817,6 +826,8 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string { // its intent, and the host refuses anything other than "estimated". getPlanCapabilities: (connectionId) => request('host.getPlanCapabilities', { connectionId }), explainPlan: (planRequest) => request('host.explainPlan', planRequest), + // Read-only metadata; the host enforces the permission and open-session gate. + getTableMetadata: (tableContext) => request('host.getTableMetadata', tableContext), saveFile: (options = {}, data) => { if (data === undefined) return request('host.saveFile', options); if (typeof data === 'string') return request('host.saveFile', { ...(options || {}), dataBase64: data }); @@ -947,6 +958,34 @@ function optionalPluginPlanScope(value: unknown, label: string): string | undefi return value.trim() ? requirePluginPlanIdentifier(value, label) : undefined; } +function requirePluginTableContext(input: Record): PluginTableContext { + const context: PluginTableContext = { + connectionId: requirePluginSchemaMetadataIdentifier(input.connectionId, "connectionId"), + table: requirePluginSchemaMetadataIdentifier(input.table, "table"), + }; + const database = optionalPluginSchemaMetadataIdentifier(input.database, "database"); + const schema = optionalPluginSchemaMetadataIdentifier(input.schema, "schema"); + if (database !== undefined) context.database = database; + if (schema !== undefined) context.schema = schema; + return context; +} + +function requirePluginSchemaMetadataIdentifier(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string`); + const identifier = value.trim(); + if (!identifier) throw new Error(`${label} must not be empty`); + if (Array.from(identifier).length > MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS) { + throw new Error(`${label} must be at most ${MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS} characters`); + } + return identifier; +} + +function optionalPluginSchemaMetadataIdentifier(value: unknown, label: string): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") throw new Error(`${label} must be a string`); + return value.trim() ? requirePluginSchemaMetadataIdentifier(value, label) : undefined; +} + /** * Validates one `host.explainPlan` request. The host owns the EXPLAIN text, so * the only accepted shape is the caller's own SQL plus a connection reference diff --git a/apps/desktop/src/types/pluginSchemaMetadata.ts b/apps/desktop/src/types/pluginSchemaMetadata.ts new file mode 100644 index 0000000000..a276ab7905 --- /dev/null +++ b/apps/desktop/src/types/pluginSchemaMetadata.ts @@ -0,0 +1,47 @@ +/** + * Read-only Plugin Host schema metadata contract (#9917). + * + * The request reuses the canonical `PluginTableContext` from the table tree; + * this file only defines the narrow response returned by the host. It does not + * expose DBX's internal `ColumnInfo`, comments, keys, credentials or SQL. + */ + +import type { PluginTableContext } from "@/types/database"; + +export type { PluginTableContext } from "@/types/database"; + +/** Permission required by `host.getTableMetadata`. */ +export const PLUGIN_SCHEMA_METADATA_PERMISSION = "host.schema:read"; + +/** Capability advertised by the bridge when the backend adapter is present. */ +export const PLUGIN_SCHEMA_METADATA_CAPABILITY = "schemaMetadataApi"; + +/** Mirrors the Rust-side identifier bound for the plugin metadata request. */ +export const MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS = 256; + +export type PluginMetadataFieldAvailability = "supported" | "unsupported" | "unknown"; + +export interface PluginTableMetadata { + columns: PluginColumnMetadata[]; + /** Conservative provider provenance for optional structured fields. */ + fieldCapabilities: PluginMetadataFieldCapabilities; +} + +export interface PluginColumnMetadata { + name: string; + dataType: string; + nullable: boolean; + length?: number | null; + precision?: number | null; + scale?: number | null; + default?: string | null; +} + +export interface PluginMetadataFieldCapabilities { + length: PluginMetadataFieldAvailability; + precision: PluginMetadataFieldAvailability; + scale: PluginMetadataFieldAvailability; + default: PluginMetadataFieldAvailability; +} + +export type PluginTableMetadataRequest = PluginTableContext; diff --git a/crates/dbx-core/src/ai/agent_loop.rs b/crates/dbx-core/src/ai/agent_loop.rs index 5bad9efbb5..22af2b8300 100644 --- a/crates/dbx-core/src/ai/agent_loop.rs +++ b/crates/dbx-core/src/ai/agent_loop.rs @@ -139,6 +139,33 @@ pub async fn run_agent_loop( max_tokens: Option, task_contract: Option<&AiTaskContract>, is_agent_mode: bool, +) -> Result { + run_agent_loop_inner( + config, + system_prompt, + messages, + agent_ctx, + on_event, + cancelled, + max_tokens, + task_contract, + is_agent_mode, + ) + .boxed() + .await +} + +#[allow(clippy::too_many_arguments)] +async fn run_agent_loop_inner( + config: &AiConfig, + system_prompt: &str, + messages: &[AiMessage], + agent_ctx: &AgentLoopContext, + on_event: impl Fn(AgentEvent) + Send + Sync + Clone + 'static, + cancelled: &Notify, + max_tokens: Option, + task_contract: Option<&AiTaskContract>, + is_agent_mode: bool, ) -> Result { let contract_system_prompt = augment_system_prompt_with_task_contract(system_prompt, task_contract, is_agent_mode); let system_prompt = contract_system_prompt.as_str(); diff --git a/crates/dbx-core/src/connection/mod.rs b/crates/dbx-core/src/connection/mod.rs index eb447678f9..ceaf1863d5 100644 --- a/crates/dbx-core/src/connection/mod.rs +++ b/crates/dbx-core/src/connection/mod.rs @@ -1406,6 +1406,55 @@ impl AppState { self.connections.read().await.keys().any(|key| pool_key_belongs_to_connection(key, connection_id)) } + /// Find an already-registered metadata/workload pool for a metadata read. + /// Unlike `get_or_create_metadata_pool_for_session`, this is a pure lookup: + /// it never validates credentials, starts an agent, or opens a transport. + pub(crate) async fn existing_metadata_pool_key_for_session( + &self, + connection_id: &str, + database: Option<&str>, + client_session_id: Option<&str>, + ) -> Option { + let config = { + let configs = self.configs.read().await; + configs.get(connection_id).cloned() + }?; + let pool_database = metadata_pool_database(Some(&config), database); + let mut base_pool_keys = + vec![base_pool_key_for_with_catalog(Some(config.db_type), connection_id, pool_database, None, false)]; + // MongoDB document operations use a connection-level pool and send the + // requested database in the command. A host connection can therefore + // legitimately be registered either under the selected database or + // under the connection-level key; probe both without creating either. + if config.db_type == DatabaseType::MongoDb { + let connection_pool_key = + base_pool_key_for_with_catalog(Some(config.db_type), connection_id, None, None, false); + if !base_pool_keys.contains(&connection_pool_key) { + base_pool_keys.push(connection_pool_key); + } + } + let connections = self.connections.read().await; + base_pool_keys + .into_iter() + .flat_map(|base_pool_key| { + [ + pool_key_for_session_role( + Some(&config), + base_pool_key.clone(), + client_session_id, + AgentSessionRole::Metadata, + ), + pool_key_for_session_role( + Some(&config), + base_pool_key, + client_session_id, + AgentSessionRole::Workload, + ), + ] + }) + .find(|pool_key| connections.pools.contains_key(pool_key)) + } + /// Mutate the registry atomically. The callback is deliberately /// synchronous; asynchronous cleanup must use values returned from it. pub async fn update_connection_pools(&self, update: impl FnOnce(&mut ConnectionPoolRegistry) -> R) -> R { diff --git a/crates/dbx-core/src/data/script_generator.rs b/crates/dbx-core/src/data/script_generator.rs index 39f1e999cd..cd863e214d 100644 --- a/crates/dbx-core/src/data/script_generator.rs +++ b/crates/dbx-core/src/data/script_generator.rs @@ -1446,6 +1446,7 @@ mod tests { enum_values: None, character_set: None, collation: None, + metadata_capabilities: None, } } @@ -2399,6 +2400,7 @@ mod tests { enum_values: None, character_set: None, collation: None, + metadata_capabilities: None, }), target: Some(ColumnInfo { name: "large_val".to_string(), @@ -2416,6 +2418,7 @@ mod tests { enum_values: None, character_set: None, collation: None, + metadata_capabilities: None, }), changes: vec!["data_type BIGINT → INT".to_string()], add_position: None, diff --git a/crates/dbx-core/src/query/mongo_ops.rs b/crates/dbx-core/src/query/mongo_ops.rs index efc0916b64..3745f420c6 100644 --- a/crates/dbx-core/src/query/mongo_ops.rs +++ b/crates/dbx-core/src/query/mongo_ops.rs @@ -232,15 +232,12 @@ pub async fn mongo_server_version_core( } } -pub async fn mongo_run_command_core( - state: &AppState, - connection_id: &str, +pub(crate) async fn mongo_run_command_with_existing_pool( + pool: &PoolKind, database: &str, command_json: &str, ) -> Result { - ensure_document_pool(state, connection_id).await?; - let pool = state.pool_handle(connection_id).await.ok_or("Not found")?; - match &pool { + match pool { PoolKind::MongoDb(client) => mongo_driver::run_command(client, database, command_json).await, PoolKind::Agent(client) => { let mut client = client.lock().await; @@ -261,6 +258,17 @@ pub async fn mongo_run_command_core( } } +pub async fn mongo_run_command_core( + state: &AppState, + connection_id: &str, + database: &str, + command_json: &str, +) -> Result { + ensure_document_pool(state, connection_id).await?; + let pool = state.pool_handle(connection_id).await.ok_or("Not found")?; + mongo_run_command_with_existing_pool(&pool, database, command_json).await +} + pub async fn mongo_show_databases_core(state: &AppState, connection_id: &str) -> Result { mongo_run_command_core(state, connection_id, MONGO_SHOW_DATABASES_DATABASE, MONGO_SHOW_DATABASES_COMMAND_JSON).await } diff --git a/crates/dbx-core/src/schema/mod.rs b/crates/dbx-core/src/schema/mod.rs index 06f9b1be8e..a7f1104f03 100644 --- a/crates/dbx-core/src/schema/mod.rs +++ b/crates/dbx-core/src/schema/mod.rs @@ -19,6 +19,9 @@ use std::time::{Duration, Instant}; mod kingbase; mod mongodb_columns; +pub mod plugin_metadata; +#[cfg(test)] +mod plugin_metadata_tests; macro_rules! extract_pool { ($pool:expr, $variant:ident) => { @@ -6859,6 +6862,22 @@ async fn retry_metadata_connection_for_session( connection_id: &str, database: Option<&str>, client_session_id: Option<&str>, + operation: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + Box::pin(run_metadata_connection_for_session(state, connection_id, database, client_session_id, true, operation)) + .await +} + +async fn run_metadata_connection_for_session( + state: &AppState, + connection_id: &str, + database: Option<&str>, + client_session_id: Option<&str>, + allow_recovery: bool, mut operation: F, ) -> Result where @@ -6875,6 +6894,9 @@ where } None => None, }; + if !allow_recovery { + return operation().await; + } let mut retried = false; let mut missing_pool_retry = false; loop { @@ -7083,21 +7105,77 @@ async fn get_columns_core_for_session_inner( table: &str, client_session_id: Option<&str>, use_client_session_context: bool, +) -> Result, String> { + get_columns_core_for_session_inner_with_pool( + state, + connection_id, + database, + schema, + table, + client_session_id, + use_client_session_context, + None, + true, + ) + .await +} + +async fn get_columns_core_for_existing_pool( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, + pool_key: &str, +) -> Result, String> { + get_columns_core_for_session_inner_with_pool( + state, + connection_id, + database, + schema, + table, + None, + true, + Some(pool_key), + false, + ) + .await +} + +async fn get_columns_core_for_session_inner_with_pool( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, + client_session_id: Option<&str>, + use_client_session_context: bool, + existing_pool_key: Option<&str>, + allow_recovery: bool, ) -> Result, String> { let context_session_id = if use_client_session_context { client_session_id } else { None }; - retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || async { - let pool_key = state - .get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id) - .await?; + let existing_pool_key = existing_pool_key.map(str::to_owned); + let operation = || async { + let pool_key = if let Some(pool_key) = existing_pool_key.as_deref() { + pool_key.to_string() + } else { + state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await? + }; let db_config = connection_config(state, connection_id).await; + if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::MongoDb) { + let pool_handle = state.pool_handle(&pool_key).await.ok_or("Pool not found")?; + return mongodb_columns::get_columns_from_existing_pool(&pool_handle, database, table).await; + } + { let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); - if uses_presto_like_information_schema_tables(&config.db_type) { - return external_driver_presto_like_columns(session, config.as_ref(), database, schema, table).await; + if uses_presto_like_information_schema_tables(&config.db_type) { + return external_driver_presto_like_columns(session, config.as_ref(), database, schema, table) + .await; } let query_oracle_columns_first = should_query_oracle_columns_via_sql_first(&config.db_type, context_session_id); @@ -7168,25 +7246,29 @@ async fn get_columns_core_for_session_inner( let database = database.to_string(); let schema = schema.to_string(); let table = table.to_string(); - return client.list_columns(database, schema, table).await; + return client.list_columns(database, schema, table).await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), ClickHouse) { - return db::clickhouse_driver::get_columns(&client, clickhouse_metadata_database(database, schema), table) - .await - .map(deduplicate_column_infos); + return db::clickhouse_driver::get_columns( + &client, + clickhouse_metadata_database(database, schema), + table, + ) + .await + .map(deduplicate_column_infos); } if let Some(client) = extract_pool!(pool_handle.as_ref(), InfluxDb) { - return db::influxdb_driver::get_columns(&client, database, table).await.map(deduplicate_column_infos); + return db::influxdb_driver::get_columns(&client, database, table).await.map(deduplicate_column_infos); } if let Some(client) = extract_pool!(pool_handle.as_ref(), InfluxDb3) { - return db::influxdb3_driver::get_columns(&client, database, table).await.map(deduplicate_column_infos); + return db::influxdb3_driver::get_columns(&client, database, table).await.map(deduplicate_column_infos); } if let Some(client) = extract_pool!(pool_handle.as_ref(), VictoriaMetrics) { - return db::victoriametrics_driver::get_columns(&client, table).await.map(deduplicate_column_infos); + return db::victoriametrics_driver::get_columns(&client, table).await.map(deduplicate_column_infos); } if let Some(linked) = crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema) { if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { - let mut client = lock_sqlserver_metadata_client(&client).await?; + let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::get_linked_server_columns( &mut client, &linked.server, @@ -7201,10 +7283,10 @@ async fn get_columns_core_for_session_inner( try_sqlserver!(pool_handle, get_columns, schema, table); if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let fallback_config = db_config.clone(); - let mut client = client.lock().await; - let oracle_sql_config = fallback_config.as_ref().filter(|config| { - should_query_oracle_columns_via_sql_first(&config.db_type, context_session_id) - }); + let mut client = client.lock().await; + let oracle_sql_config = fallback_config + .as_ref() + .filter(|config| should_query_oracle_columns_via_sql_first(&config.db_type, context_session_id)); let query_oracle_columns_first = oracle_sql_config.is_some(); if let Some(config) = oracle_sql_config { match oracle_columns_via_sql( @@ -7252,7 +7334,9 @@ async fn get_columns_core_for_session_inner( ) .await { - Ok(fallback_columns) if !fallback_columns.is_empty() => return Ok(fallback_columns), + Ok(fallback_columns) if !fallback_columns.is_empty() => { + return Ok(fallback_columns) + } Ok(_) => {} Err(error) => { log::warn!( @@ -7266,6 +7350,8 @@ async fn get_columns_core_for_session_inner( } } } + } + if let Some(config) = fallback_config.as_ref().filter(|_| existing_pool_key.is_none()) { match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { return db::postgres::get_columns(&pool, schema, table) @@ -7288,7 +7374,7 @@ async fn get_columns_core_for_session_inner( return Ok(deduplicate_column_infos(columns)); } Err(agent_error) => { - if let Some(config) = fallback_config.as_ref() { + if let Some(config) = fallback_config.as_ref().filter(|_| existing_pool_key.is_none()) { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { @@ -7354,9 +7440,9 @@ async fn get_columns_core_for_session_inner( PoolKind::Turso(client) => { db::turso_driver::get_columns(client, schema, table).await.map(deduplicate_column_infos) } - PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::get_columns(client, schema, table) - .await - .map(deduplicate_column_infos), + PoolKind::CloudflareD1(client) => { + db::cloudflare_d1_driver::get_columns(client, schema, table).await.map(deduplicate_column_infos) + } PoolKind::Elasticsearch(client) => { db::elasticsearch_driver::get_columns(client, table).await.map(deduplicate_column_infos) } @@ -7371,7 +7457,15 @@ async fn get_columns_core_for_session_inner( } _ => Ok(vec![]), } - }) + }; + Box::pin(run_metadata_connection_for_session( + state, + connection_id, + Some(database), + client_session_id, + allow_recovery, + operation, + )) .await } @@ -7413,6 +7507,9 @@ fn deduplicate_column_infos(columns: Vec) -> Vec if existing.data_type.trim().is_empty() && !column.data_type.trim().is_empty() { existing.data_type = column.data_type; } + if existing.metadata_capabilities != column.metadata_capabilities { + existing.metadata_capabilities = None; + } } else { result.push(column); } diff --git a/crates/dbx-core/src/schema/mongodb_columns.rs b/crates/dbx-core/src/schema/mongodb_columns.rs index 8d0d411aef..8a3ea04d29 100644 --- a/crates/dbx-core/src/schema/mongodb_columns.rs +++ b/crates/dbx-core/src/schema/mongodb_columns.rs @@ -3,28 +3,48 @@ use std::collections::{BTreeMap, BTreeSet}; use mongodb::bson::Bson; use serde_json::{json, Value}; -use crate::connection::AppState; +use crate::connection::{AppState, PoolKind}; use crate::db::mongo_driver::MongoDocumentResult; use crate::db::ColumnInfo; const SAMPLE_SIZE: usize = 100; const MAX_FIELDS: usize = 512; +fn sample_command(collection: &str) -> Value { + json!({ + "find": collection, + "filter": {}, + "limit": SAMPLE_SIZE, + "batchSize": SAMPLE_SIZE, + "singleBatch": true, + "maxTimeMS": 5000, + }) +} + pub(super) async fn get_columns( state: &AppState, connection_id: &str, database: &str, collection: &str, ) -> Result, String> { - let command = json!({ - "find": collection, - "filter": {}, - "limit": SAMPLE_SIZE, - "batchSize": SAMPLE_SIZE, - "singleBatch": true, - "maxTimeMS": 5000, - }); - let result = crate::mongo_ops::mongo_run_command_core(state, connection_id, database, &command.to_string()).await?; + let result = crate::mongo_ops::mongo_run_command_core( + state, + connection_id, + database, + &sample_command(collection).to_string(), + ) + .await?; + columns_from_sample(&result) +} + +pub(super) async fn get_columns_from_existing_pool( + pool: &PoolKind, + database: &str, + collection: &str, +) -> Result, String> { + let result = + crate::mongo_ops::mongo_run_command_with_existing_pool(pool, database, &sample_command(collection).to_string()) + .await?; columns_from_sample(&result) } @@ -68,6 +88,7 @@ fn columns_from_sample(result: &MongoDocumentResult) -> Result, enum_values: None, character_set: None, collation: None, + metadata_capabilities: None, }) .collect()) } diff --git a/crates/dbx-core/src/schema/plugin_metadata.rs b/crates/dbx-core/src/schema/plugin_metadata.rs new file mode 100644 index 0000000000..05d2e5d920 --- /dev/null +++ b/crates/dbx-core/src/schema/plugin_metadata.rs @@ -0,0 +1,287 @@ +//! Narrow, read-only schema metadata exposed through the plugin Host API. +//! +//! The request is the wire representation of the desktop `PluginTableContext`: +//! it carries table identity only, never a connection config or credential. The +//! implementation deliberately enters the existing schema abstraction instead +//! of issuing driver-specific metadata SQL from the plugin layer. + +use serde::{Deserialize, Serialize}; + +use crate::connection::AppState; +use crate::db; +use crate::types::ColumnMetadataCapabilities; + +pub const MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS: usize = 256; +pub const CONNECTION_NOT_OPEN_ERROR: &str = "Connection is not open"; +pub const TABLE_METADATA_SESSION_NOT_OPEN_ERROR: &str = "Connection session is not open for the requested database"; + +/// The Rust wire DTO intentionally mirrors the public TypeScript +/// `PluginTableContext` field-for-field. It is not a second table identity +/// model; it is the camelCase JSON boundary used by Tauri and Web transports. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTableContext { + pub connection_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub table: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTableMetadata { + pub columns: Vec, + /// Reports whether structured optional fields are exposed by the provider + /// path. `unknown` is deliberate when an external provider gives DBX no + /// provenance; the host never guesses from a database type. + pub field_capabilities: PluginMetadataFieldCapabilities, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginColumnMetadata { + pub name: String, + pub data_type: String, + pub nullable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub precision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scale: Option, + #[serde(rename = "default", default, skip_serializing_if = "Option::is_none")] + pub default_value: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMetadataFieldCapabilities { + pub length: PluginMetadataFieldAvailability, + pub precision: PluginMetadataFieldAvailability, + pub scale: PluginMetadataFieldAvailability, + #[serde(rename = "default")] + pub default_value: PluginMetadataFieldAvailability, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginMetadataFieldAvailability { + Supported, + Unsupported, + Unknown, +} + +/// Resolve one table's columns through DBX's existing metadata subsystem. +/// +/// The open-connection check is intentionally before `get_columns_core`: that +/// ordinary DBX path is allowed to create metadata pools for the application, +/// but a plugin request must not turn a saved configuration into an implicit +/// connection. A closed request returns without touching the pool registry. +pub async fn get_table_metadata(state: &AppState, context: PluginTableContext) -> Result { + let context = validate_plugin_table_context(context)?; + let config = { + let configs = state.configs.read().await; + configs.get(&context.connection_id).cloned() + } + .ok_or_else(|| "Connection config not found".to_string())?; + + if !state.is_connection_open(&context.connection_id).await { + return Err(CONNECTION_NOT_OPEN_ERROR.to_string()); + } + + // A table contribution may omit a scope when the selected database does + // not expose one. When the host already knows a saved/default scope, use + // that existing identity; otherwise the schema subsystem receives the + // same empty scope used by its normal metadata path. + let database = context.database.as_deref().or_else(|| config.effective_database()).unwrap_or_default(); + let schema = context.schema.as_deref().or(config.default_schema.as_deref()).unwrap_or_default(); + let pool_key = state + .existing_metadata_pool_key_for_session(&context.connection_id, Some(database), None) + .await + .ok_or_else(|| TABLE_METADATA_SESSION_NOT_OPEN_ERROR.to_string())?; + let columns = super::get_columns_core_for_existing_pool( + state, + &context.connection_id, + database, + schema, + &context.table, + &pool_key, + ) + .await?; + if columns.is_empty() { + return Err("Table metadata provider returned no columns".to_string()); + } + let field_capabilities = field_capabilities(&columns); + + Ok(PluginTableMetadata { columns: columns.into_iter().map(map_column).collect(), field_capabilities }) +} + +fn validate_plugin_table_context(mut context: PluginTableContext) -> Result { + context.connection_id = required_identifier(context.connection_id, "connectionId")?; + context.table = required_identifier(context.table, "table")?; + context.database = optional_identifier(context.database, "database")?; + context.schema = optional_identifier(context.schema, "schema")?; + Ok(context) +} + +fn required_identifier(value: String, name: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{name} must not be empty")); + } + if value.chars().count() > MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS { + return Err(format!("{name} must be at most {MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS} characters")); + } + Ok(value.to_string()) +} + +fn optional_identifier(value: Option, name: &str) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim(); + if value.is_empty() { + return Ok(None); + } + if value.chars().count() > MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS { + return Err(format!("{name} must be at most {MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS} characters")); + } + Ok(Some(value.to_string())) +} + +fn map_column(column: db::ColumnInfo) -> PluginColumnMetadata { + PluginColumnMetadata { + name: column.name, + data_type: column.data_type, + nullable: column.is_nullable, + length: column.character_maximum_length, + precision: column.numeric_precision, + scale: column.numeric_scale, + default_value: column.column_default, + } +} + +fn field_capabilities(columns: &[db::ColumnInfo]) -> PluginMetadataFieldCapabilities { + PluginMetadataFieldCapabilities { + length: field_capability(columns, |capabilities| capabilities.length), + precision: field_capability(columns, |capabilities| capabilities.precision), + scale: field_capability(columns, |capabilities| capabilities.scale), + default_value: field_capability(columns, |capabilities| capabilities.default), + } +} + +fn field_capability( + columns: &[db::ColumnInfo], + field: impl Fn(ColumnMetadataCapabilities) -> bool, +) -> PluginMetadataFieldAvailability { + let Some(first) = columns.first().and_then(|column| column.metadata_capabilities) else { + return PluginMetadataFieldAvailability::Unknown; + }; + if columns.iter().all(|column| column.metadata_capabilities.is_some_and(|capabilities| capabilities == first)) { + if field(first) { + PluginMetadataFieldAvailability::Supported + } else { + PluginMetadataFieldAvailability::Unsupported + } + } else { + PluginMetadataFieldAvailability::Unknown + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::Storage; + + fn config() -> crate::models::connection::ConnectionConfig { + serde_json::from_value(serde_json::json!({ + "id": "conn-1", + "name": "Connection", + "db_type": "postgres", + "host": "127.0.0.1", + "port": 5432, + "username": "user", + "password": "secret" + })) + .unwrap() + } + + fn column(name: &str) -> db::ColumnInfo { + db::ColumnInfo { + name: name.to_string(), + data_type: "character varying".to_string(), + is_nullable: true, + column_default: None, + numeric_precision: None, + numeric_scale: None, + character_maximum_length: Some(64), + metadata_capabilities: Some(ColumnMetadataCapabilities::all_supported()), + ..Default::default() + } + } + + #[test] + fn maps_column_info_to_the_narrow_public_contract() { + let metadata = PluginTableMetadata { + columns: vec![map_column(column("name"))], + field_capabilities: field_capabilities(&[column("name")]), + }; + let value = serde_json::to_value(metadata).unwrap(); + assert_eq!(value["columns"][0]["name"], "name"); + assert_eq!(value["columns"][0]["dataType"], "character varying"); + assert_eq!(value["columns"][0]["nullable"], true); + assert_eq!(value["columns"][0]["length"], 64); + assert!(value["columns"][0].get("default").is_none()); + assert!(value["columns"][0].get("comment").is_none()); + assert_eq!(value["fieldCapabilities"]["length"], "supported"); + } + + #[test] + fn does_not_guess_capabilities_without_provider_provenance() { + let column = db::ColumnInfo { name: "id".to_string(), data_type: "integer".to_string(), ..Default::default() }; + let capabilities = field_capabilities(&[column]); + assert_eq!(capabilities.length, PluginMetadataFieldAvailability::Unknown); + assert_eq!(capabilities.precision, PluginMetadataFieldAvailability::Unknown); + } + + #[test] + fn preserves_unsupported_structured_fields_without_fabricating_values() { + let mut column_info = column("id"); + column_info.character_maximum_length = None; + column_info.numeric_precision = None; + column_info.numeric_scale = None; + column_info.metadata_capabilities = Some(ColumnMetadataCapabilities::default_only()); + let metadata = PluginTableMetadata { + columns: vec![map_column(column_info)], + field_capabilities: field_capabilities(&[db::ColumnInfo { + metadata_capabilities: Some(ColumnMetadataCapabilities::default_only()), + ..column("id") + }]), + }; + let value = serde_json::to_value(metadata).unwrap(); + assert!(value["columns"][0].get("length").is_none()); + assert_eq!(value["fieldCapabilities"]["length"], "unsupported"); + assert_eq!(value["fieldCapabilities"]["default"], "supported"); + } + + #[tokio::test] + async fn saved_but_disconnected_context_is_rejected_without_creating_a_pool() { + let dir = tempfile::tempdir().unwrap(); + let storage = Storage::open(&dir.path().join("storage.db")).await.unwrap(); + let state = AppState::new_with_plugin_dir(storage, dir.path().join("plugins")); + let config = config(); + state.configs.write().await.insert(config.id.clone(), config); + + let context = PluginTableContext { + connection_id: "conn-1".to_string(), + database: Some("app".to_string()), + schema: Some("public".to_string()), + table: "users".to_string(), + }; + let error = get_table_metadata(&state, context).await.unwrap_err(); + assert_eq!(error, CONNECTION_NOT_OPEN_ERROR); + assert!(state.with_connection_pools(|pools| pools.is_empty()).await); + } +} diff --git a/crates/dbx-core/src/schema/plugin_metadata_tests.rs b/crates/dbx-core/src/schema/plugin_metadata_tests.rs new file mode 100644 index 0000000000..169fcaa64c --- /dev/null +++ b/crates/dbx-core/src/schema/plugin_metadata_tests.rs @@ -0,0 +1,276 @@ +use super::plugin_metadata::{get_table_metadata, PluginTableContext, TABLE_METADATA_SESSION_NOT_OPEN_ERROR}; +use super::{ + get_columns_core_for_existing_pool, get_columns_core_for_session_inner, run_metadata_connection_for_session, +}; +use crate::connection::{AppState, PoolKind, METADATA_POOL_ACQUIRE_TIMEOUT}; +use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec, AgentRuntimeClient}; +use crate::models::connection::{ConnectionConfig, DatabaseType}; +use crate::storage::Storage; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +async fn state(db_type: DatabaseType) -> (tempfile::TempDir, AppState) { + let dir = tempfile::tempdir().unwrap(); + let storage = Storage::open(&dir.path().join("storage.db")).await.unwrap(); + let state = AppState::new_with_plugin_and_agent_dir_and_app_version( + storage, + dir.path().join("plugins"), + dir.path().join("agents"), + "test", + ); + let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id": "conn", + "name": "Metadata test", + "db_type": db_type, + "host": "127.0.0.1", + "port": 5432, + "username": "user", + "password": "secret", + "database": "app", + "connect_timeout_secs": 1 + })) + .unwrap(); + state.configs.write().await.insert(config.id.clone(), config); + (dir, state) +} + +fn context() -> PluginTableContext { + PluginTableContext { + connection_id: "conn".to_string(), + database: Some("app".to_string()), + schema: Some("public".to_string()), + table: "users".to_string(), + } +} + +struct ScriptedAgent(Arc); + +impl Drop for ScriptedAgent { + fn drop(&mut self) { + self.0.kill(); + } +} + +async fn scripted_agent(dir: &std::path::Path, response: serde_json::Value) -> ScriptedAgent { + let script = dir.join("metadata-agent.py"); + std::fs::write( + &script, + r#"import json, pathlib, sys +response = json.loads(sys.argv[1]) +calls = pathlib.Path(sys.argv[2]) +print(json.dumps({'ready': True}), flush=True) +for line in sys.stdin: + request = json.loads(line) + method = request['method'] + if method == 'handshake': + body = {'result': {'protocolVersion': 2, 'agentProtocolVersion': 2, 'capabilities': ['multi_session']}} + elif method == 'get_columns': + with calls.open('a') as output: + output.write(method + '\n') + body = response + else: + body = {'result': {}} + print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], **body}), flush=True) +"#, + ) + .unwrap(); + let python = if cfg!(windows) { "python" } else { "python3" }; + let runtime = AgentRuntimeClient::spawn( + AgentLaunchSpec::new(python).with_args([ + script.to_string_lossy().to_string(), + response.to_string(), + dir.join("calls").to_string_lossy().to_string(), + ]), + "test", + ) + .await + .unwrap(); + ScriptedAgent(runtime) +} + +#[tokio::test] +async fn existing_pool_never_connects_on_empty_or_error_but_default_app_keeps_fallback() { + for db_type in [DatabaseType::Highgo, DatabaseType::Vastbase] { + for provider_error in [false, true] { + let (dir, state) = state(db_type).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + state.configs.write().await.get_mut("conn").unwrap().port = listener.local_addr().unwrap().port(); + let response = if provider_error { + serde_json::json!({"error": {"code": -1, "message": "metadata denied", "data": { + "category": "sql", "sessionDisposition": "keep" + }}}) + } else { + serde_json::json!({"result": []}) + }; + let agent = scripted_agent(dir.path(), response).await; + agent.0.increment_session_count(); + let client = AgentDriverClient::shared_session(agent.0.clone(), "metadata-session".to_string()); + let pool_key = "conn:app:role:metadata"; + state.update_connection_pools(|pools| pools.insert(pool_key.to_string(), PoolKind::agent(client))).await; + + let error = tokio::time::timeout(Duration::from_secs(3), async { + tokio::select! { + biased; + _ = listener.accept() => panic!("plugin metadata attempted a native connection"), + result = get_table_metadata(&state, context()) => result.unwrap_err(), + } + }) + .await + .expect("plugin metadata must not wait for a native fallback"); + if provider_error { + assert!(error.contains("metadata denied"), "{error}"); + assert_eq!( + crate::db::agent_driver::try_agent_error_from_legacy(&error).and_then(|error| error.category()), + Some(crate::db::agent_driver::AgentErrorCategory::Sql) + ); + } else { + assert_eq!(error, "Table metadata provider returned no columns"); + } + assert_eq!(std::fs::read_to_string(dir.path().join("calls")).unwrap().lines().count(), 1); + assert!(state.pool_handle(pool_key).await.is_some()); + assert_eq!(state.with_connection_pools(|pools| pools.len()).await, 1); + + tokio::time::timeout(Duration::from_secs(3), async { + tokio::select! { + accepted = listener.accept() => { accepted.unwrap(); } + result = get_columns_core_for_session_inner(&state, "conn", "app", "public", "users", None, true) => { + panic!("ordinary app skipped its native fallback: {result:?}"); + } + } + }) + .await + .expect("ordinary app fallback must still connect"); + assert_eq!(std::fs::read_to_string(dir.path().join("calls")).unwrap().lines().count(), 2); + + agent.0.kill(); + let stale_error = tokio::time::timeout(Duration::from_secs(3), async { + tokio::select! { + biased; + _ = listener.accept() => panic!("stale plugin pool triggered a native connection"), + result = get_table_metadata(&state, context()) => result.unwrap_err(), + } + }) + .await + .expect("cancelled metadata must release the agent lock"); + assert!(stale_error.contains("unavailable"), "{stale_error}"); + assert!(state.pool_handle(pool_key).await.is_some()); + assert_eq!(state.with_connection_pools(|pools| pools.len()).await, 1); + } + } +} + +#[tokio::test] +async fn existing_pool_shares_app_concurrency_limit_and_queue_timeout() { + for db_type in [DatabaseType::Mysql, DatabaseType::Postgres] { + let (_dir, state) = state(db_type).await; + tokio::time::pause(); + let entered = AtomicUsize::new(0); + let mut operations = Vec::new(); + for index in 0..6 { + let operation = run_metadata_connection_for_session(&state, "conn", Some("app"), None, index < 3, || { + entered.fetch_add(1, Ordering::SeqCst); + std::future::pending::>() + }); + let mut operation = Box::pin(operation); + assert!(futures::poll!(operation.as_mut()).is_pending()); + operations.push(operation); + } + assert_eq!(entered.load(Ordering::SeqCst), 6); + let mut queued = + Box::pin(run_metadata_connection_for_session(&state, "conn", Some("app"), None, false, || { + entered.fetch_add(1, Ordering::SeqCst); + async { Ok(()) } + })); + assert!(futures::poll!(queued.as_mut()).is_pending()); + tokio::time::advance(METADATA_POOL_ACQUIRE_TIMEOUT).await; + assert_eq!(queued.await.unwrap_err(), crate::query::METADATA_POOL_BUSY_ERROR); + assert_eq!(entered.load(Ordering::SeqCst), 6); + drop(operations); + for _ in 0..6 { + run_metadata_connection_for_session(&state, "conn", Some("app"), None, false, || async { Ok(()) }) + .await + .unwrap(); + } + tokio::time::resume(); + } +} + +#[tokio::test] +async fn existing_pool_entrypoint_waits_for_the_shared_budget_before_provider_lookup() { + let (_dir, state) = state(DatabaseType::Postgres).await; + let mut held = Vec::new(); + for _ in 0..6 { + held.push(state.acquire_metadata_permit("conn", Some("app"), DatabaseType::Postgres, None).await.unwrap()); + } + let mut lookup = Box::pin(get_columns_core_for_existing_pool(&state, "conn", "app", "public", "users", "conn:app")); + assert!(futures::poll!(lookup.as_mut()).is_pending()); + drop(held.pop()); + assert_eq!(lookup.await.unwrap_err(), "Pool not found"); + let mut next = Box::pin(state.acquire_metadata_permit("conn", Some("app"), DatabaseType::Postgres, None)); + assert!(matches!(futures::poll!(next.as_mut()), std::task::Poll::Ready(Ok(_)))); +} + +#[tokio::test] +async fn existing_pool_releases_permits_on_success_error_and_cancellation_without_retry() { + let (_dir, state) = state(DatabaseType::Postgres).await; + let mut held = Vec::new(); + for _ in 0..5 { + held.push(state.acquire_metadata_permit("conn", Some("app"), DatabaseType::Postgres, None).await.unwrap()); + } + for result in [Ok(()), Err("Pool not found"), Err("connection reset by peer"), Err("Query canceled")] { + let mut attempts = 0; + let actual = run_metadata_connection_for_session(&state, "conn", Some("app"), None, false, || { + attempts += 1; + async { result.map_err(str::to_string) } + }) + .await; + assert_eq!(actual, result.map_err(str::to_string)); + assert_eq!(attempts, 1); + } + let mut running = Box::pin(run_metadata_connection_for_session(&state, "conn", Some("app"), None, false, || { + std::future::pending::>() + })); + assert!(futures::poll!(running.as_mut()).is_pending()); + let queued_calls = AtomicUsize::new(0); + let mut queued = Box::pin(run_metadata_connection_for_session(&state, "conn", Some("app"), None, false, || { + queued_calls.fetch_add(1, Ordering::SeqCst); + async { Ok(()) } + })); + assert!(futures::poll!(queued.as_mut()).is_pending()); + drop(queued); + drop(running); + assert_eq!(queued_calls.load(Ordering::SeqCst), 0); + let mut next = Box::pin(state.acquire_metadata_permit("conn", Some("app"), DatabaseType::Postgres, None)); + assert!(matches!(futures::poll!(next.as_mut()), std::task::Poll::Ready(Ok(_)))); +} + +#[tokio::test] +async fn invalid_scope_and_removed_pool_do_not_create_or_reconnect() { + let (_dir, state) = state(DatabaseType::Postgres).await; + let mut invalid = context(); + invalid.table = " ".to_string(); + assert_eq!(get_table_metadata(&state, invalid).await.unwrap_err(), "table must not be empty"); + let mut unknown = context(); + unknown.connection_id = "unknown".to_string(); + assert_eq!(get_table_metadata(&state, unknown).await.unwrap_err(), "Connection config not found"); + state + .update_connection_pools(|pools| { + pools.insert("conn:other".to_string(), PoolKind::agent(AgentDriverClient::test_stub())); + }) + .await; + assert_eq!(get_table_metadata(&state, context()).await.unwrap_err(), TABLE_METADATA_SESSION_NOT_OPEN_ERROR); + state + .update_connection_pools(|pools| { + pools.remove("conn:other"); + pools.insert("conn:app".to_string(), PoolKind::agent(AgentDriverClient::test_stub())); + }) + .await; + let key = state.existing_metadata_pool_key_for_session("conn", Some("app"), None).await.unwrap(); + state.update_connection_pools(|pools| pools.remove(&key)).await; + assert_eq!( + get_columns_core_for_existing_pool(&state, "conn", "app", "public", "users", &key).await.unwrap_err(), + "Pool not found" + ); + assert!(state.with_connection_pools(|pools| pools.is_empty()).await); +} diff --git a/crates/dbx-core/tests/api_contract_verification.rs b/crates/dbx-core/tests/api_contract_verification.rs index e66b287d84..42b6796bfa 100644 --- a/crates/dbx-core/tests/api_contract_verification.rs +++ b/crates/dbx-core/tests/api_contract_verification.rs @@ -293,6 +293,7 @@ fn column_info_serialization_roundtrip() { enum_values: None, character_set: None, collation: None, + metadata_capabilities: None, }; let json = serde_json::to_value(&col).unwrap(); assert_eq!(json.get("is_unique"), Some(&serde_json::json!(true))); @@ -340,6 +341,7 @@ fn table_columns_result_serialization_contract() { enum_values: None, character_set: None, collation: None, + metadata_capabilities: None, }], error: Some("partial".to_string()), }; diff --git a/crates/dbx-core/tests/bidirectional_diff_e2e.rs b/crates/dbx-core/tests/bidirectional_diff_e2e.rs index 80c80f0358..0e71a1597c 100644 --- a/crates/dbx-core/tests/bidirectional_diff_e2e.rs +++ b/crates/dbx-core/tests/bidirectional_diff_e2e.rs @@ -27,6 +27,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo { numeric_precision: None, numeric_scale: None, character_maximum_length: None, + metadata_capabilities: None, enum_values: None, character_set: None, collation: None, diff --git a/crates/dbx-core/tests/cross_dialect_integration.rs b/crates/dbx-core/tests/cross_dialect_integration.rs index 8d9cba14f9..d6001c6e6f 100644 --- a/crates/dbx-core/tests/cross_dialect_integration.rs +++ b/crates/dbx-core/tests/cross_dialect_integration.rs @@ -30,6 +30,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo { numeric_precision: None, numeric_scale: None, character_maximum_length: None, + metadata_capabilities: None, enum_values: None, character_set: None, collation: None, diff --git a/crates/dbx-core/tests/performance_benchmarks.rs b/crates/dbx-core/tests/performance_benchmarks.rs index a7e20bc6c5..38a54268dc 100644 --- a/crates/dbx-core/tests/performance_benchmarks.rs +++ b/crates/dbx-core/tests/performance_benchmarks.rs @@ -48,6 +48,7 @@ fn generate_details(tables: &[TableInfo], columns_per_table: usize) -> Vec ColumnInfo { numeric_precision: None, numeric_scale: None, character_maximum_length: None, + metadata_capabilities: None, enum_values: None, character_set: None, collation: None, diff --git a/crates/dbx-core/tests/schema_diff_table_mapping.rs b/crates/dbx-core/tests/schema_diff_table_mapping.rs index 814b3853c4..2cc98e3af2 100644 --- a/crates/dbx-core/tests/schema_diff_table_mapping.rs +++ b/crates/dbx-core/tests/schema_diff_table_mapping.rs @@ -29,6 +29,7 @@ fn column(name: &str, data_type: &str) -> ColumnInfo { numeric_precision: None, numeric_scale: None, character_maximum_length: None, + metadata_capabilities: None, enum_values: None, character_set: None, collation: None, diff --git a/crates/dbx-drivers/src/db/meilisearch_driver.rs b/crates/dbx-drivers/src/db/meilisearch_driver.rs index d0b99add62..ec43de760b 100644 --- a/crates/dbx-drivers/src/db/meilisearch_driver.rs +++ b/crates/dbx-drivers/src/db/meilisearch_driver.rs @@ -395,6 +395,7 @@ pub async fn get_columns(client: &MeilisearchClient, index: &str) -> Result ColumnInfo { numeric_scale: row.try_get::<_, Option>(offset + 8).ok().flatten(), character_maximum_length: row.try_get::<_, Option>(offset + 9).ok().flatten(), enum_values: parse_enum_values_from_row(row, offset + 10), + metadata_capabilities: Some(ColumnMetadataCapabilities::all_supported()), ..Default::default() } } @@ -7622,6 +7624,7 @@ fn redshift_columns_from_query_result(result: QueryResult) -> Vec { numeric_scale: query_result_i32(&row, 5), character_maximum_length: query_result_i32(&row, 6), enum_values: None, + metadata_capabilities: Some(ColumnMetadataCapabilities::all_supported()), ..Default::default() }) }) diff --git a/crates/dbx-drivers/src/db/sqlite.rs b/crates/dbx-drivers/src/db/sqlite.rs index 1775839240..6500831637 100644 --- a/crates/dbx-drivers/src/db/sqlite.rs +++ b/crates/dbx-drivers/src/db/sqlite.rs @@ -18,9 +18,9 @@ extern crate libsqlite3_hotbundle; use super::file_validator::validate_file_path; use crate::sql::starts_with_executable_sql_keyword; use crate::types::{ - ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode, - CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo, - ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo, + ColumnInfo, ColumnMetadataCapabilities, CompletionAssistantCandidate, CompletionAssistantCandidateKind, + CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest, + CompletionAssistantResponse, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo, }; const SQLITE_DATABASE_HEADER: &[u8; 16] = b"SQLite format 3\0"; @@ -1588,6 +1588,7 @@ pub async fn get_columns(pool: &SqliteHandle, schema: &str, table: &str) -> Resu numeric_scale: None, character_maximum_length: None, enum_values: None, + metadata_capabilities: Some(ColumnMetadataCapabilities::default_only()), ..Default::default() } }) @@ -1623,6 +1624,7 @@ pub async fn get_columns(pool: &SqliteHandle, schema: &str, table: &str) -> Resu numeric_scale: None, character_maximum_length: None, enum_values: None, + metadata_capabilities: Some(ColumnMetadataCapabilities::default_only()), ..Default::default() }) }) diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index be51cceb49..6854f14f59 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -2570,6 +2570,7 @@ fn infer_document_columns(documents: &[Value]) -> Vec { enum_values: None, character_set: None, collation: None, + metadata_capabilities: None, }) .collect() } diff --git a/crates/dbx-plugin-runtime/src/plugins/manifest.rs b/crates/dbx-plugin-runtime/src/plugins/manifest.rs index 2c8591c463..50a3f001f8 100644 --- a/crates/dbx-plugin-runtime/src/plugins/manifest.rs +++ b/crates/dbx-plugin-runtime/src/plugins/manifest.rs @@ -9,11 +9,12 @@ pub const SUPPORTED_PLUGIN_MANIFEST_VERSION: u32 = 1; /// /// 1.1 adds the plugin-initiated `host/requestUserInput` method (see /// `plugins/runtime.rs`). 1.2 adds the plugin-initiated plan Host API -/// (`host.getPlanCapabilities` / `host.explainPlan`). Both are additive: older -/// plugins keep working, and a plugin that wants either capability must check -/// the advertised version (or the matching `capabilities` / `host.features` +/// (`host.getPlanCapabilities` / `host.explainPlan`). 1.3 adds read-only table +/// schema metadata (`host.getTableMetadata`). All are additive: older plugins +/// keep working, and a plugin that wants either capability must check the +/// advertised version (or the matching `capabilities` / `host.features` /// entry) before calling it. -pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.2.0"; +pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.3.0"; /// Capabilities the host advertises to a plugin backend at `plugin/initialize`. pub const SUPPORTED_PLUGIN_HOST_FEATURES: &[&str] = &["host.requestUserInput"]; pub const SUPPORTED_PLUGIN_PROTOCOL_VERSION: u32 = 1; @@ -21,8 +22,16 @@ pub const PLUGIN_CONNECTION_TEST_METHOD: &str = "connection/test"; pub const PLUGIN_CONNECTION_CONNECT_METHOD: &str = "connection/connect"; pub const PLUGIN_CONNECTION_DISCONNECT_METHOD: &str = "connection/disconnect"; pub const PLUGIN_CONNECTION_ACTION_METHOD: &str = "connection/action"; -pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = - &["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.storage", "host.ai"]; +pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = &[ + "host.events", + "host.binary", + "host.workbench", + "host.filesystem", + "host.plans:read", + "host.schema:read", + "host.storage", + "host.ai", +]; /// Cap the number of `host.network:` entries so a manifest cannot bloat /// the sandbox CSP or enumerate large origin lists. @@ -1572,7 +1581,7 @@ mod tests { } #[test] - fn accepts_host_plans_read_and_still_rejects_unknown_permissions() { + fn accepts_read_only_host_permissions_and_still_rejects_unknown_permissions() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("ui")).unwrap(); std::fs::write(dir.path().join("ui").join("index.html"), "").unwrap(); @@ -1585,14 +1594,22 @@ mod tests { "publisher": "example", "engines": { "dbx": ">=0.1.0", "host_api": "^1.0" }, "entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }, - "permissions": ["host.plans:read"] + "permissions": ["host.plans:read", "host.schema:read"] })) .unwrap(); let compatibility = manifest.compatibility(dir.path(), "0.1.0"); assert!(compatibility.compatible, "{:?}", compatibility.errors); - // The plan API is read-only: an execute-scoped scope must not be declared. - for permission in ["host.plans:execute", "host.plans", "host.plans:read:all", "host.plan:read"] { + // Both the plan and schema metadata APIs are read-only; execute/write + // scopes must not be declared as substitutes. + for permission in [ + "host.plans:execute", + "host.plans", + "host.plans:read:all", + "host.plan:read", + "host.schema:write", + "host.schema", + ] { let manifest: PluginManifest = serde_json::from_value(serde_json::json!({ "manifest_version": 1, "id": "io.dbx.example", @@ -1635,22 +1652,22 @@ mod tests { assert_eq!(declared, SUPPORTED_PLUGIN_PERMISSIONS.iter().map(|value| value.to_string()).collect::>()); } - /// The reason for the 1.2.0 bump: `engines.host_api` is how a plugin states - /// "I need the plan API", so the advertised version has to satisfy `^1.2` - /// while a floor this host cannot meet stays rejected. + /// The reason for the 1.3.0 bump: `engines.host_api` is how a plugin states + /// "I need the schema metadata API", so the advertised version has to + /// satisfy `^1.3` while a floor this host cannot meet stays rejected. #[test] - fn host_api_advertises_the_floor_a_plan_api_plugin_declares() { + fn host_api_advertises_the_floor_a_schema_metadata_plugin_declares() { let advertised = semver::Version::parse(SUPPORTED_PLUGIN_HOST_API_VERSION) .expect("the advertised Host API version must be semver"); assert!( - semver::VersionReq::parse("^1.2").unwrap().matches(&advertised), - "the host must satisfy the plan API floor it asks plugins to declare" + semver::VersionReq::parse("^1.3").unwrap().matches(&advertised), + "the host must satisfy the schema metadata API floor it asks plugins to declare" ); - for requirement in ["^1.0", "^1.1", "^1.2", ">=1.1.0, <2.0.0"] { + for requirement in ["^1.0", "^1.1", "^1.2", "^1.3", ">=1.1.0, <2.0.0"] { assert!(host_api_requirement_errors(requirement).is_empty(), "{requirement} must be satisfiable"); } - for requirement in [">=1.3.0", "^2.0"] { + for requirement in [">=1.4.0", "^2.0"] { assert!(!host_api_requirement_errors(requirement).is_empty(), "{requirement} must be rejected"); } } diff --git a/crates/dbx-sql/src/schema_diff.rs b/crates/dbx-sql/src/schema_diff.rs index c6bf66965c..4f2f474839 100644 --- a/crates/dbx-sql/src/schema_diff.rs +++ b/crates/dbx-sql/src/schema_diff.rs @@ -6374,6 +6374,7 @@ mod tests { numeric_precision: None, numeric_scale: None, character_maximum_length: None, + metadata_capabilities: None, enum_values: None, character_set: None, collation: None, @@ -9214,6 +9215,7 @@ mod tests { numeric_precision: None, numeric_scale: None, character_maximum_length: None, + metadata_capabilities: None, enum_values: None, character_set: None, collation: None, @@ -12410,6 +12412,7 @@ mod tests { numeric_precision: None, numeric_scale: None, character_maximum_length: None, + metadata_capabilities: None, enum_values: None, character_set: None, collation: None, @@ -12427,6 +12430,7 @@ mod tests { numeric_precision: None, numeric_scale: None, character_maximum_length: None, + metadata_capabilities: None, enum_values: None, character_set: None, collation: None, diff --git a/crates/dbx-types/src/types.rs b/crates/dbx-types/src/types.rs index 30981120e4..af6ec6be02 100644 --- a/crates/dbx-types/src/types.rs +++ b/crates/dbx-types/src/types.rs @@ -181,6 +181,26 @@ pub struct ObjectSource { pub editable: Option, } +/// Provenance for structured metadata fields that are optional in [`ColumnInfo`]. +/// This stays internal to the metadata mapping path and is not serialized. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColumnMetadataCapabilities { + pub default: bool, + pub length: bool, + pub precision: bool, + pub scale: bool, +} + +impl ColumnMetadataCapabilities { + pub const fn all_supported() -> Self { + Self { default: true, length: true, precision: true, scale: true } + } + + pub const fn default_only() -> Self { + Self { default: true, length: false, precision: false, scale: false } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ColumnInfo { pub name: String, @@ -203,6 +223,8 @@ pub struct ColumnInfo { pub character_set: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub collation: Option, + #[serde(skip)] + pub metadata_capabilities: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 6d41ec0ba5..afd7815a4e 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -566,6 +566,7 @@ async fn serve() { .route("/schema/event-info", get(routes::schema::get_event_info)) .route("/schema/custom-type-details", get(routes::schema::get_custom_type_details)) .route("/schema/columns", get(routes::schema::list_columns)) + .route("/plugin/table-metadata", post(routes::schema::get_plugin_table_metadata)) .route("/schema/all-columns", get(routes::schema::get_all_columns)) .route("/schema/data-types", get(routes::schema::list_data_types)) .route("/schema/indexes", get(routes::schema::list_indexes)) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index e175d4da55..e457eaa184 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -462,6 +462,15 @@ fn should_cache_columns(client_session_id: Option<&str>) -> bool { client_session_id.is_none() } +pub async fn get_plugin_table_metadata( + State(state): State>, + Json(request): Json, +) -> Result, AppError> { + let result = + dbx_core::schema::plugin_metadata::get_table_metadata(&state.app, request).await.map_err(AppError::from)?; + Ok(Json(result)) +} + pub async fn list_columns( State(state): State>, Query(q): Query, diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 02224d078d..f58f71e0ef 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -103,6 +103,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 "host.filesystem", "host.binary", "host.plans:read", + "host.schema:read", "host.storage", "host.network:https://s3.example.com:443" ], @@ -120,7 +121,8 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 - `engines.dbx` 是可选的 DBX 版本范围;`engines.host_api` 是必需的 Host API 版本声明。 - `host.workbench` 允许打开另一个工作台,`host.events` 接收宿主事件,`host.binary` 使用二进制通道,`host.filesystem` 使用文件系统入口。 - `host.plans:read` 允许针对 DBX 自己的连接读取估算执行计划(见 [估算执行计划](#估算执行计划))。它是只读权限:不授予普通 SQL 执行、写操作、DDL 或真实执行计划,且这是唯一存在的计划类权限。 -- `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储(见 [持久化 UI 状态](#持久化-ui-状态));数据只落在插件自己的数据目录里。 +- `host.schema:read` 允许通过 `host.getTableMetadata` 读取已打开连接的窄化 table schema metadata;它不会授予任意 SQL、写入或隐式重连(见 [Table Schema Metadata](#table-schema-metadata))。 +- `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储(见 [持久化 UI 状态](#持久化-ui-状态));数据只落在插件自己的插件数据目录里。 - `host.ai` 允许插件在 DBX 内置 AI 面板中新建带数据快照的对话,沿用宿主的模型选择、历史、追问、停止和导出功能。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力。 - `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。 - `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。 @@ -313,7 +315,8 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `openFilesystem(id, context)` | 打开本插件的文件系统入口;需要 `host.filesystem`。 | | `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 | | `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 | -| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, storage, ai }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 | +| `getTableMetadata({ connectionId, database?, schema?, table })` | 读取已打开连接的窄化 table schema metadata;需要 `host.schema:read`。 | +| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, storage, ai }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 | | `ai.openConversation({ title, prompt, context, send? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,为 `true` 时立即分析;需要 `host.ai`,并以 `capabilities.ai` 广播——旧宿主不带该字段,请按能力位关闭而不是用真实请求试探。 | | `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 | | `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 | @@ -367,6 +370,59 @@ if (window.dbxPlugin.capabilities.planApi) { 计划 API 属于 Host API 1.2。无法离开它工作的插件应在 manifest 里声明下限:`"engines": { "host_api": "^1.2" }`。manifest 里的版本范围是兼容下限,`capabilities.planApi` 是运行期探测,两者都保留。 +### Table Schema Metadata(表结构元数据) + +插件可以读取当前 DBX 已打开连接中某个 table 的窄化 schema metadata,而不需要拥有驱动、连接池、凭据或自己拼 SQL。请求复用现有的 `PluginTableContext`,只允许 table identity: + +```json +{ + "permissions": ["host.schema:read"] +} +``` + +```js +if (window.dbxPlugin.capabilities.schemaMetadataApi) { + const metadata = await window.dbxPlugin.getTableMetadata({ + connectionId, + database, + schema, + table: "users" + }); + for (const column of metadata.columns) { + renderColumn(column.name, column.dataType, column.nullable); + } +} +``` + +`getTableMetadata({ connectionId, database?, schema?, table })` 返回: + +```json +{ + "columns": [ + { + "name": "id", + "dataType": "integer", + "nullable": false, + "precision": 32, + "default": "nextval('users_id_seq'::regclass)" + } + ], + "fieldCapabilities": { + "length": "supported", + "precision": "supported", + "scale": "supported", + "default": "supported" + } +} +``` + +- `columns` 只包含 `name`、`dataType`、`nullable`、可选的 `length`、`precision`、`scale` 和 `default`。不会返回 comment、key/index、credential、连接串、驱动内部对象或任意 SQL 结果。 +- 可选值在没有值时保持 `null` 或直接省略,绝不会被伪造为 `0` 或空字符串。`fieldCapabilities` 用 `supported`、`unsupported`、`unknown` 区分提供方支持、明确不支持和宿主没有可靠 provenance 的情况;`unknown` 不应被插件当成支持。 +- `database` 和 `schema` 可选;不可用时省略,不要把空字符串当作 public identity。`connectionId` 和 `table` 必须是非空且不超过 256 个字符的 identity 值。 +- 这是只读 API。DBX 只复用已打开的 Host connection/session,禁止插件隐式重连、创建连接池或执行任意 SQL。已保存但当前断开的连接会以 `Connection is not open` 拒绝;请求的 database 没有对应已打开 session 时也会拒绝,而不是切换到别的连接。 + +该 API 属于 Host API 1.3。无法离开它工作的插件应声明:`"engines": { "host_api": "^1.3" }`;同时仍应读取 `capabilities.schemaMetadataApi`,因为旧宿主不会提供该字段。没有 `host.schema:read` 时,宿主会在请求到达 backend 前拒绝调用。 + ### 文件传输与系统拖放 桌面端宿主通过 `window.dbxPlugin.fileTransfer` 把本地文件流式接入插件沙箱。句柄只在用户明确同意后打开:原生打开/保存对话框(`pick` / `beginSave`),或从系统拖放到本插件工作台区域内的文件(`onDrop`)。传输按分块进行,多 GB 文件也不会整体载入内存。 diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index 4c310fc67a..bd56ab9301 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -103,6 +103,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con "host.filesystem", "host.binary", "host.plans:read", + "host.schema:read", "host.storage", "host.network:https://s3.example.com:443" ], @@ -120,6 +121,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con - `engines.dbx` is an optional DBX version range; `engines.host_api` is required. - `host.workbench` opens another workbench, `host.events` receives host events, `host.binary` enables binary channels, and `host.filesystem` enables filesystem entrypoints. - `host.plans:read` allows reading estimated execution plans for DBX's own connections (see [Estimated Execution Plans](#estimated-execution-plans)). It is read-only: it never grants SQL execution, writes, DDL, or actual plans, and it is the only plan scope that exists. +- `host.schema:read` allows `host.getTableMetadata` to read narrow table schema metadata from an already-open connection; it grants no arbitrary SQL, writes, or implicit reconnect (see [Table Schema Metadata](#table-schema-metadata)). - `host.storage` gives the workbench UI a persistent key-value store scoped to this plugin (see [Persistent UI State](#persistent-ui-state)). Values never leave the plugin's own data directory. - `host.ai` lets the plugin open a conversation with a data snapshot in the built-in DBX AI panel, using its model selector, history, follow-up messages, cancellation and export. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional. - `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall. @@ -313,7 +315,8 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `openFilesystem(id, context)` | Opens a filesystem entry; requires `host.filesystem`. | | `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. | | `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. | -| `capabilities` | `{ downloadFile, planApi, storage, ai }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. | +| `getTableMetadata({ connectionId, database?, schema?, table })` | Reads narrow table schema metadata from an already-open connection; requires `host.schema:read`. | +| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, storage, ai }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. | | `ai.openConversation({ title, prompt, context, send? })` | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false`, or starts analysis when `true`; requires `host.ai` and is advertised as `capabilities.ai` — gate on it instead of probing, since older hosts omit it. | | `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. | | `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. | @@ -367,6 +370,59 @@ Gate on capability rather than probing: read `dbxPlugin.capabilities.planApi` fr The plan API is Host API 1.2. A plugin that cannot work without it declares the floor in its manifest with `"engines": { "host_api": "^1.2" }`. The manifest range is a compatibility floor and `capabilities.planApi` is the runtime check; keep both. +### Table Schema Metadata + +A plugin can read narrow schema metadata for one table on a DBX connection that is already open, without owning a driver, connection pool, credential, or SQL string. The request reuses the canonical `PluginTableContext` and contains table identity only: + +```json +{ + "permissions": ["host.schema:read"] +} +``` + +```js +if (window.dbxPlugin.capabilities.schemaMetadataApi) { + const metadata = await window.dbxPlugin.getTableMetadata({ + connectionId, + database, + schema, + table: "users" + }); + for (const column of metadata.columns) { + renderColumn(column.name, column.dataType, column.nullable); + } +} +``` + +`getTableMetadata({ connectionId, database?, schema?, table })` returns: + +```json +{ + "columns": [ + { + "name": "id", + "dataType": "integer", + "nullable": false, + "precision": 32, + "default": "nextval('users_id_seq'::regclass)" + } + ], + "fieldCapabilities": { + "length": "supported", + "precision": "supported", + "scale": "supported", + "default": "supported" + } +} +``` + +- `columns` contains only `name`, `dataType`, `nullable`, and optional `length`, `precision`, `scale`, and `default`. It never returns comments, keys/indexes, credentials, connection strings, driver objects, or arbitrary SQL results. +- Optional values remain `null` or are omitted when no value exists; they are never fabricated as `0` or an empty string. `fieldCapabilities` uses `supported`, `unsupported`, and `unknown` to distinguish provider support, an explicitly unavailable field, and missing reliable provenance; plugins must not treat `unknown` as supported. +- `database` and `schema` are optional and omitted when unavailable; do not use empty strings as public identity. `connectionId` and `table` must be non-empty identity values no longer than 256 characters. +- This API is read-only. DBX reuses only an already-open Host connection/session; a plugin cannot trigger an implicit reconnect, create a pool, or execute arbitrary SQL. A saved-but-disconnected connection is rejected with `Connection is not open`; a requested database without a matching open session is also rejected instead of being routed to another connection. + +This API is part of Host API 1.3. A plugin that cannot work without it should declare `"engines": { "host_api": "^1.3" }` and still read `capabilities.schemaMetadataApi`, because older hosts omit that field. Without `host.schema:read`, the host rejects the request before it reaches the backend. + ### File Transfer and OS Drops Desktop hosts stream local files into plugin sandboxes through `window.dbxPlugin.fileTransfer`. Handles are opened only after explicit user consent: a native open/save dialog (`pick` / `beginSave`) or a file dropped from the OS onto this plugin's workbench area (`onDrop`). Transfers are chunked, so multi-gigabyte files never load into memory. diff --git a/packages/app-tests/pluginSchemaMetadataHostApi.test.ts b/packages/app-tests/pluginSchemaMetadataHostApi.test.ts new file mode 100644 index 0000000000..44de00aab4 --- /dev/null +++ b/packages/app-tests/pluginSchemaMetadataHostApi.test.ts @@ -0,0 +1,130 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import { PluginHostBridge } from "../../apps/desktop/src/lib/plugins/pluginHostBridge.ts"; +import { + MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS, + PLUGIN_SCHEMA_METADATA_PERMISSION, +} from "../../apps/desktop/src/types/pluginSchemaMetadata.ts"; +import type { PluginTableContext, PluginTableMetadata } from "../../apps/desktop/src/types/pluginSchemaMetadata.ts"; +import type { InstalledPlugin, PluginWorkbenchContribution } from "../../apps/desktop/src/types/database.ts"; + +const coreSource = readFileSync("crates/dbx-core/src/schema/plugin_metadata.rs", "utf8"); +const manifestSource = readFileSync("crates/dbx-plugin-runtime/src/plugins/manifest.rs", "utf8"); +const manifestSchema = JSON.parse(readFileSync("plugins/manifest.schema.json", "utf8")); + +function rustStructFields(source: string, name: string): string[] { + const marker = `pub struct ${name} {`; + const start = source.indexOf(marker); + assert.notEqual(start, -1, `Could not find struct ${name}`); + let depth = 0; + let end = start; + for (let index = source.indexOf("{", start); index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") { + depth -= 1; + if (depth === 0) { + end = index; + break; + } + } + } + return source + .slice(start, end) + .split("\n") + .map((line) => /^\s*pub\s+([a-z0-9_]+)\s*:/.exec(line)?.[1]) + .filter((field): field is string => field !== undefined); +} + +function camelCase(value: string): string { + return value.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()); +} + +function plugin(permissions: string[]): InstalledPlugin { + return { + manifest: { id: "sample", name: "Sample", version: "1.0.0", permissions, drivers: [], contributions: [] }, + compatibility: { compatible: true }, + } as InstalledPlugin; +} + +const workbench = { type: "workbench", id: "sample.main", label: "Sample" } as PluginWorkbenchContribution; + +function sandbox() { + const messages: Record[] = []; + const target = { postMessage: (message: unknown) => messages.push(message as Record) } as unknown as Window; + return { messages, target }; +} + +function send(bridge: PluginHostBridge, target: Window, params: unknown, id = "1"): void { + bridge.handleWindowMessage({ + source: target, + data: { source: "dbx-plugin", version: 1, type: "request", id, method: "host.getTableMetadata", params }, + } as MessageEvent); +} + +async function until(check: () => boolean, what: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (check()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for ${what}`); +} + +test("schema metadata request and response fields match the Rust/TS contract", () => { + assert.deepEqual(rustStructFields(coreSource, "PluginTableContext").map(camelCase), ["connectionId", "database", "schema", "table"]); + assert.deepEqual(rustStructFields(coreSource, "PluginColumnMetadata").map((field) => (field === "default_value" ? "default" : camelCase(field))), [ + "name", + "dataType", + "nullable", + "length", + "precision", + "scale", + "default", + ]); + assert.deepEqual(rustStructFields(coreSource, "PluginTableMetadata").map(camelCase), ["columns", "fieldCapabilities"]); + assert.ok(manifestSource.includes(`"${PLUGIN_SCHEMA_METADATA_PERMISSION}"`)); + assert.ok(manifestSchema.properties.permissions.items.anyOf[0].enum.includes(PLUGIN_SCHEMA_METADATA_PERMISSION)); + assert.equal(MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS, 256); +}); + +test("the bridge sends only the canonical table context and never reaches an undeclared plugin adapter", async () => { + const { messages, target } = sandbox(); + const sent: PluginTableContext[] = []; + const result: PluginTableMetadata = { + columns: [{ name: "id", dataType: "integer", nullable: false }], + fieldCapabilities: { length: "unknown", precision: "unknown", scale: "unknown", default: "unknown" }, + }; + const bridge = new PluginHostBridge(plugin([PLUGIN_SCHEMA_METADATA_PERMISSION]), workbench, {}, () => target, { + invoke: async () => undefined as T, + notify: async () => undefined, + sendBinary: async () => undefined, + readAsset: async () => ({ dataBase64: "", contentType: "" }), + getTableMetadata: async (context) => { + sent.push(context); + return result; + }, + }); + + send(bridge, target, { connectionId: " c1 ", database: "app", schema: " ", table: " users " }); + await until(() => sent.length === 1, "the metadata request"); + assert.deepEqual(sent[0], { connectionId: "c1", database: "app", table: "users" }); + await until(() => messages.some((message) => message.id === "1"), "the metadata response"); + assert.deepEqual(messages.find((message) => message.id === "1")?.result, result); + + const denied = sandbox(); + let called = false; + const deniedBridge = new PluginHostBridge(plugin([]), workbench, {}, () => denied.target, { + invoke: async () => undefined as T, + notify: async () => undefined, + sendBinary: async () => undefined, + readAsset: async () => ({ dataBase64: "", contentType: "" }), + getTableMetadata: async () => { + called = true; + return result; + }, + }); + send(deniedBridge, denied.target, { connectionId: "c1", table: "users" }, "denied"); + await until(() => denied.messages.length === 1, "the permission rejection"); + assert.match(String(denied.messages[0].error), /host\.schema:read/); + assert.equal(called, false); +}); diff --git a/plugins/README.md b/plugins/README.md index 26197b0a7c..15687bf79d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -371,9 +371,10 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san - `openWorkbench(contributionId, context)` — requires `host.workbench` - `openFilesystem(providerId, context)` — requires `host.filesystem` - `getPlanCapabilities(connectionId)` / `explainPlan(request)` — reads an estimated execution plan for one connection; requires `host.plans:read`, see [Estimated execution plans](#estimated-execution-plans) +- `getTableMetadata({ connectionId, database?, schema?, table })` — reads narrow schema metadata for one table on an already-open connection; requires `host.schema:read`, see [Table Schema Metadata](#table-schema-metadata) - `storage` — `storage.get(key)` / `storage.set(key, value)` / `storage.delete(key)` persist small JSON state per plugin in `plugin-data/`; requires `host.storage`; values cap at 256 KiB and the whole store at 1 MiB, bulk data belongs in the sidecar's `DBX_PLUGIN_DATA_DIR` - `ai.openConversation({ title, prompt, context, send? })` — opens a plugin conversation in the built-in DBX AI panel from a snapshot the plugin supplies; requires `host.ai`; `title` caps at 200 characters, `prompt` at 32000, and `context` at 2 MiB, and `send` defaults to `false` so pass `true` to start the analysis immediately. The host copies the snapshot into the conversation as history data and never hands model output or model configuration back to the plugin -- `capabilities` — `{ downloadFile, planApi, storage, ai }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request +- `capabilities` — `{ downloadFile, planApi, schemaMetadataApi, storage, ai }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request - `onEvent(listener)` — events are forwarded only with `host.events` - `onBinary(listener)` — binary frames are forwarded only with `host.binary`; listeners receive `{ channel, data: Uint8Array }` @@ -500,6 +501,64 @@ Every entry has `name`, canonical `uri`, `kind` (`file`, `directory`, `symlink`, Mutation methods return `{ success, message?, entry? }` and are rejected unless the provider declares the matching capability. Inline read/write payloads are capped at 4 MiB. The built-in file manager currently owns directory navigation, pagination, and bounded file preview. Large upload/download and PTY/SFTP streams use `stdio-framed` binary channels with plugin-defined transfer methods, chunk acknowledgements, cancellation, and progress events; they must not be encoded as one large JSON value. +### Table Schema Metadata + +A plugin can read narrow, read-only schema metadata for one table without owning a driver, connection pool, credential, or SQL string. It reuses the canonical `PluginTableContext` identity used by table contributions: + +```json +{ + "permissions": ["host.schema:read"] +} +``` + +```js +if (window.dbxPlugin.capabilities.schemaMetadataApi) { + const metadata = await window.dbxPlugin.getTableMetadata({ + connectionId, + database, + schema, + table: "users" + }); + renderColumns(metadata.columns); +} +``` + +The result is deliberately narrower than DBX's internal `ColumnInfo`: + +```json +{ + "columns": [ + { + "name": "id", + "dataType": "integer", + "nullable": false, + "precision": 32, + "default": "nextval('users_id_seq'::regclass)" + } + ], + "fieldCapabilities": { + "length": "supported", + "precision": "supported", + "scale": "supported", + "default": "supported" + } +} +``` + +`columns` exposes only `name`, `dataType`, `nullable`, and optional `length`, `precision`, `scale`, and `default`. Comments, keys/indexes, credentials, connection strings, driver objects, and arbitrary SQL results never cross the boundary. Optional values remain omitted or `null`; the host never turns missing metadata into `0` or an empty string. `fieldCapabilities` reports `supported`, `unsupported`, or `unknown` for each structured optional field. `unknown` means DBX lacks reliable provider provenance and must not be treated as support. + +`database` and `schema` are optional and omitted when unavailable. `connectionId` and `table` are non-empty, trimmed identity values capped at 256 characters. The host must already hold the matching connection/session: a saved-but-disconnected connection is rejected with `Connection is not open`, and a database without an open matching session is rejected instead of creating a pool or switching connections. The permission grants no arbitrary SQL and no write access. + +This is Host API 1.3. A plugin that requires it declares: + +```json +{ + "engines": { "host_api": "^1.3" } +} +``` + +The `schemaMetadataApi` capability is runtime detection for older hosts; a plugin should gate the call on it rather than probing the request. Without `host.schema:read`, the bridge rejects the call before the backend adapter runs. + ### Estimated execution plans A plugin can read the **estimated** execution plan of a query without touching a database driver, a credential, or a connection string. The host builds the `EXPLAIN` statement with its own `build_explain_sql`, applies the same read-only safety gate DBX uses for its own plan view, and executes it on the connection the plugin names. The plugin receives only the raw plan. @@ -692,7 +751,7 @@ Sidecars are shared per plugin process, not spawned per tab. Plugins own their i - **UI isolation:** sandboxed iframe, restrictive CSP, bounded bridge payloads, safe asset paths, plugin identity binding. - **Secret persistence:** plugin secrets are removed from connection JSON and stored through DBX's secret-store path. Ordinary cloud-sync snapshots always contain redacted placeholders. Secrets enter sync data only inside the encrypted payload when the user has configured a sync passphrase; without one, plugin secrets remain local and are not synchronized. - **Native backend trust:** a native sidecar runs with the current OS user's privileges. A signature identifies the repository that approved and published the package; it is not an OS sandbox or proof that the author is harmless. Install only plugins whose backend code you trust. -- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/` directory; it grants no other filesystem reach. `host.ai` lets a workbench open a built-in AI conversation seeded with a snapshot the plugin supplies; the plugin gets no model output, no model configuration, and no SQL execution out of it. +- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.schema:read` grants only narrow metadata for a table on an already-open host connection; it never grants arbitrary SQL, writes, or reconnects. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/` directory; it grants no other filesystem reach. `host.ai` lets a workbench open a built-in AI conversation seeded with a snapshot the plugin supplies; the plugin gets no model output, no model configuration, and no SQL execution out of it. Custom repository public keys can be added or removed in Plugin Center. Obtain them through a channel independent from the downloaded package. diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json index 81f657c15a..37d2e5fab8 100644 --- a/plugins/manifest.schema.json +++ b/plugins/manifest.schema.json @@ -30,7 +30,7 @@ "uniqueItems": true, "items": { "anyOf": [ - { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.storage", "host.ai"] }, + { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai"] }, { "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" } ] } diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 477b86cfe2..d6f55662de 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -388,6 +388,16 @@ pub async fn get_columns( .await } +/// Read-only Plugin Host API over the existing connection. The core function +/// performs the open-connection gate before entering the ordinary metadata path. +#[tauri::command] +pub async fn get_plugin_table_metadata( + state: State<'_, Arc>, + request: dbx_core::schema::plugin_metadata::PluginTableContext, +) -> Result { + dbx_core::schema::plugin_metadata::get_table_metadata(&state, request).await +} + #[tauri::command] pub async fn get_all_columns( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f4c7a1075c..64f8d108cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1984,6 +1984,7 @@ pub fn run() { commands::schema::list_schema_infos, commands::schema::list_data_types, commands::schema::get_columns, + commands::schema::get_plugin_table_metadata, commands::schema::get_all_columns, commands::schema::get_sqlserver_column_metadata, commands::schema::list_indexes,