From f909f85075e12beb78dffe5942f7d039bede25de Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sun, 20 Sep 2026 17:15:52 +0800 Subject: [PATCH] feat(plugins): expose read-only estimated plan host api --- .../plugins/PluginWorkbenchHost.vue | 4 + apps/desktop/src/lib/backend/api.ts | 6 + apps/desktop/src/lib/backend/http.ts | 18 +- apps/desktop/src/lib/backend/tauri.ts | 20 +- .../src/lib/plugins/pluginHostBridge.spec.ts | 223 ++++- .../src/lib/plugins/pluginHostBridge.ts | 86 +- apps/desktop/src/types/pluginPlan.ts | 79 ++ crates/dbx-core/src/ai/agent_explain.rs | 16 +- crates/dbx-core/src/ai/agent_tools.rs | 1 + crates/dbx-core/src/connection/mod.rs | 69 +- crates/dbx-core/src/query/mod.rs | 1 + crates/dbx-core/src/query/plugin_plan.rs | 854 ++++++++++++++++++ .../src/plugins/manifest.rs | 119 ++- crates/dbx-sql/src/query_execution_sql.rs | 82 ++ crates/dbx-web/src/main.rs | 2 + crates/dbx-web/src/routes/query.rs | 30 + docs/content/docs/plugin-development.cn.mdx | 49 + docs/content/docs/plugin-development.mdx | 49 + packages/app-tests/pluginPlanHostApi.test.ts | 261 ++++++ plugins/README.md | 84 +- plugins/manifest.schema.json | 2 +- src-tauri/src/commands/query.rs | 19 + src-tauri/src/lib.rs | 2 + 23 files changed, 2059 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/types/pluginPlan.ts create mode 100644 crates/dbx-core/src/query/plugin_plan.rs create mode 100644 packages/app-tests/pluginPlanHostApi.test.ts diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index 1ccf923209..729105e365 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -83,6 +83,10 @@ function createBridge() { openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options), openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context), reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId), + // Both plan calls carry the plugin's declared `host.plans:read` gate in the + // bridge; the backend owns EXPLAIN generation, the timeout, and the plan cap. + getPlanCapabilities: (connectionId) => api.getPluginPlanCapabilities(connectionId), + explainPlan: (request) => api.getPluginEstimatedPlan(request), 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 ef44f0c2de..55f391128b 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -23,6 +23,10 @@ async function getBackend(): Promise { // --------------------------------------------------------------------------- function forward(name: K): Backend[K] { + // SAFETY: the resolved module is one of exactly two implementations of the same + // surface — `http.ts` imports its request/response types from `tauri.ts` on + // purpose, so both transports are kept signature-compatible by construction. + // TypeScript cannot express that cross-module equivalence, hence the cast. return (async (...args: unknown[]) => { const startedAt = performance.now(); const operation = String(name); @@ -262,6 +266,8 @@ export const prepareQueryPaginationExecutionPlan = forward("prepareQueryPaginati export const buildSortedQuerySql = forward("buildSortedQuerySql"); export const buildExplainSql = forward("buildExplainSql"); export const getExplainInfo = forward("getExplainInfo"); +export const getPluginPlanCapabilities = forward("getPluginPlanCapabilities"); +export const getPluginEstimatedPlan = forward("getPluginEstimatedPlan"); export const buildCreateUserSql = forward("buildCreateUserSql"); export const buildDroppedFilePreviewSql = forward("buildDroppedFilePreviewSql"); export const buildTableSelectSql = forward("buildTableSelectSql"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 66b1229e53..7707a46582 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -160,6 +160,9 @@ import type { QuerySqlBuildResult, BuildExplainSqlOptions, ExplainSqlBuildResult, + PluginPlanCapabilities, + PluginPlanRequest, + PluginPlanResult, DroppedFilePreviewSqlOptions, MongoGridFsFileInfo, AppSupportInfo, @@ -1567,6 +1570,19 @@ export async function getExplainInfo(connectionId: string, database: string | un }); } +/** Plugin Host API: what the host and this connection can plan. Never connects. */ +export async function getPluginPlanCapabilities(connectionId: string): Promise { + return post("/api/query/plugin-plan-capabilities", { connectionId }); +} + +/** + * Plugin Host API: acquires the estimated plan for caller-supplied SQL. The + * backend generates and owns the EXPLAIN statement; the request cannot carry one. + */ +export async function getPluginEstimatedPlan(request: PluginPlanRequest): Promise { + return post("/api/query/plugin-estimated-plan", request); +} + export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise { const result = await post("/api/query/build-dropped-file-preview-sql", { options }); return result ?? undefined; @@ -4493,7 +4509,7 @@ export async function elasticsearchCountDocuments(connectionId: string, index: s }); } -export async function elasticsearchGetIndexMetadata(connectionId: string, index: string, kind: ElasticsearchIndexMetadataKind): Promise> { +export async function elasticsearchGetIndexMetadata(connectionId: string, index: string, kind: ElasticsearchIndexMetadataKind): Promise> { return post("/api/document-store/elasticsearch/index-metadata", { connectionId, index, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 1c3eab0f56..289b390569 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -3,6 +3,9 @@ import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRest import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes"; import { assertUpdateAllowsCommand } from "@/lib/app/updatePreparation"; 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"; function invoke(command: string, args?: Record): Promise { assertUpdateAllowsCommand(command); @@ -791,6 +794,8 @@ 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"; import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence"; import { uuid } from "@/lib/common/utils"; @@ -1807,6 +1812,19 @@ export async function getExplainInfo(connectionId: string, database: string | un }); } +/** Plugin Host API: what the host and this connection can plan. Never connects. */ +export async function getPluginPlanCapabilities(connectionId: string): Promise { + return invoke("get_plugin_plan_capabilities", { connectionId }); +} + +/** + * Plugin Host API: acquires the estimated plan for caller-supplied SQL. The + * backend generates and owns the EXPLAIN statement; the request cannot carry one. + */ +export async function getPluginEstimatedPlan(request: PluginPlanRequest): Promise { + return invoke("get_plugin_estimated_plan", { request }); +} + export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise { const result = await invoke("build_dropped_file_preview_sql", { options, @@ -4291,7 +4309,7 @@ export interface ElasticsearchDeleteByQueryResult { failures: string[]; } -export async function elasticsearchGetIndexMetadata(connectionId: string, index: string, kind: ElasticsearchIndexMetadataKind): Promise> { +export async function elasticsearchGetIndexMetadata(connectionId: string, index: string, kind: ElasticsearchIndexMetadataKind): Promise> { return invoke("elasticsearch_get_index_metadata", { connectionId, index, diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index 714da1a171..fa8b1fdbe4 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -324,6 +324,205 @@ describe("PluginHostBridge", () => { expect(messages[2]).toMatchObject({ id: "not-plugin", error: "Connection is not plugin-backed" }); }); + // --- Plugin plan Host API (#9675) ---------------------------------------- + + const planCapabilities = { + dbType: "postgres", + dbVersion: "15.19", + supports: { estimatedPlan: true }, + limits: { maxTimeoutMs: 60_000, maxPlanBytes: 4 * 1024 * 1024 }, + }; + + function planCall(bridge: PluginHostBridge, target: Window, method: string, params: unknown, id: string): void { + bridge.handleWindowMessage({ + source: target, + data: { source: "dbx-plugin", version: 1, type: "request", id, method, params }, + } as MessageEvent); + } + + function planHost() { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const getPlanCapabilities = vi.fn().mockResolvedValue(planCapabilities); + const explainPlan = vi.fn().mockResolvedValue({ + dbType: "postgres", + format: "json", + rawPlan: [{ Plan: { "Node Type": "Seq Scan" } }], + truncated: false, + warnings: [], + }); + return { messages, target, getPlanCapabilities, explainPlan }; + } + + it("requires host.plans:read before any plan call", async () => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const getPlanCapabilities = vi.fn(); + const explainPlan = vi.fn(); + // Permission is the only gate: an undeclared plugin must not reach the host + // even when the host could serve the request. + const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + getPlanCapabilities, + explainPlan, + }); + + planCall(bridge, target, "host.getPlanCapabilities", { connectionId: "c1" }, "caps"); + planCall(bridge, target, "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated" }, "plan"); + await vi.waitFor(() => expect(messages).toHaveLength(2)); + + expect(messages[0]).toMatchObject({ id: "caps", error: "Plugin has not declared permission 'host.plans:read'" }); + expect(messages[1]).toMatchObject({ id: "plan", error: "Plugin has not declared permission 'host.plans:read'" }); + expect(getPlanCapabilities).not.toHaveBeenCalled(); + expect(explainPlan).not.toHaveBeenCalled(); + }); + + it("dispatches an estimated plan request and returns the raw plan", async () => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const getPlanCapabilities = vi.fn().mockResolvedValue(planCapabilities); + const explainPlan = vi.fn().mockResolvedValue({ + dbType: "postgres", + format: "json", + rawPlan: [{ Plan: { "Node Type": "Seq Scan" } }], + truncated: true, + warnings: ["plan_truncated"], + }); + const bridge = new PluginHostBridge(plugin(["host.plans:read"]), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + getPlanCapabilities, + explainPlan, + }); + + planCall(bridge, target, "host.getPlanCapabilities", { connectionId: " c1 " }, "caps"); + planCall(bridge, target, "host.explainPlan", { connectionId: "c1", database: "app", schema: " ", sql: " SELECT * FROM orders ", mode: "estimated", timeoutMs: 1_500.4 }, "plan"); + await vi.waitFor(() => expect(messages).toHaveLength(2)); + + expect(getPlanCapabilities).toHaveBeenCalledWith("c1"); + // The plugin never sends EXPLAIN text: the host receives the source SQL, + // trimmed, with blank scopes dropped rather than forwarded as empty strings. + expect(explainPlan).toHaveBeenCalledWith({ connectionId: "c1", database: "app", sql: "SELECT * FROM orders", mode: "estimated", timeoutMs: 1_500 }); + expect(messages[0]).toMatchObject({ id: "caps", result: planCapabilities }); + expect(messages[1]).toMatchObject({ + id: "plan", + result: { dbType: "postgres", format: "json", truncated: true, warnings: ["plan_truncated"] }, + }); + }); + + it("refuses every plan mode other than estimated", async () => { + const { messages, target, explainPlan } = planHost(); + const bridge = new PluginHostBridge(plugin(["host.plans:read"]), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + explainPlan, + }); + + // Actual plans execute the statement, so an unexpected mode is refused + // rather than silently downgraded to an estimated one. + for (const [id, mode] of [ + ["actual", "actual"], + ["analyze", "analyze"], + ["case", "Estimated"], + ["missing", undefined], + ] as const) { + planCall(bridge, target, "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode }, id); + } + await vi.waitFor(() => expect(messages).toHaveLength(4)); + + for (const message of messages) { + expect(message).toMatchObject({ error: 'host.explainPlan serves mode "estimated" only' }); + } + expect(explainPlan).not.toHaveBeenCalled(); + }); + + it("rejects malformed plan requests before they reach the host", async () => { + const { messages, target, getPlanCapabilities, explainPlan } = planHost(); + const bridge = new PluginHostBridge(plugin(["host.plans:read"]), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + getPlanCapabilities, + explainPlan, + }); + + const cases: [string, string, unknown, string][] = [ + ["caps-missing", "host.getPlanCapabilities", {}, "connectionId"], + ["caps-blank", "host.getPlanCapabilities", { connectionId: " " }, "connectionId"], + ["caps-type", "host.getPlanCapabilities", { connectionId: 7 }, "connectionId"], + ["caps-long", "host.getPlanCapabilities", { connectionId: "c".repeat(257) }, "connectionId"], + ["sql-missing", "host.explainPlan", { connectionId: "c1", mode: "estimated" }, "sql"], + ["sql-blank", "host.explainPlan", { connectionId: "c1", sql: " \n ", mode: "estimated" }, "sql"], + ["sql-long", "host.explainPlan", { connectionId: "c1", sql: "x".repeat(200_001), mode: "estimated" }, "sql"], + ["scope-type", "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated", database: 1 }, "database"], + ["timeout-type", "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated", timeoutMs: "soon" }, "timeoutMs"], + ["timeout-nan", "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated", timeoutMs: Number.NaN }, "timeoutMs"], + ]; + for (const [id, method, params] of cases) planCall(bridge, target, method, params, id); + planCall(bridge, target, "host.explainPlan", "not an object", "params-object"); + await vi.waitFor(() => expect(messages).toHaveLength(cases.length + 1)); + + cases.forEach(([id, , , expected], index) => { + expect(messages[index]).toMatchObject({ id }); + expect((messages[index] as { error?: string }).error).toContain(expected); + }); + expect(messages[cases.length]).toMatchObject({ id: "params-object", error: "host.explainPlan params must be an object" }); + expect(getPlanCapabilities).not.toHaveBeenCalled(); + expect(explainPlan).not.toHaveBeenCalled(); + }); + + it("clamps the requested plan timeout to the host ceiling", async () => { + const { messages, target, explainPlan } = planHost(); + const bridge = new PluginHostBridge(plugin(["host.plans:read"]), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + explainPlan, + }); + + planCall(bridge, target, "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated", timeoutMs: 10 * 60_000 }, "huge"); + planCall(bridge, target, "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated", timeoutMs: -5 }, "tiny"); + planCall(bridge, target, "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated" }, "absent"); + await vi.waitFor(() => expect(messages).toHaveLength(3)); + + expect(explainPlan.mock.calls[0][0].timeoutMs).toBe(60_000); + expect(explainPlan.mock.calls[1][0].timeoutMs).toBe(1); + // No timeout is a host decision; the bridge must not invent one. + expect(explainPlan.mock.calls[2][0]).not.toHaveProperty("timeoutMs"); + }); + + it("advertises the plan API only when the host can serve it", async () => { + const messages: any[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const base = { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() }; + + const capable = new PluginHostBridge(plugin(["host.plans:read"]), workbench, {}, () => target, { + ...base, + getPlanCapabilities: vi.fn().mockResolvedValue(planCapabilities), + explainPlan: vi.fn().mockResolvedValue({ dbType: "postgres", format: "json", rawPlan: {}, truncated: false, warnings: [] }), + }); + capable.sendInit(); + expect(messages[0].capabilities).toMatchObject({ planApi: true }); + + // An older host omits the adapters: the plugin must see planApi false and + // get an explicit failure instead of a silent no-op. + const legacy = new PluginHostBridge(plugin(["host.plans:read"]), workbench, {}, () => target, { ...base }); + legacy.sendInit(); + expect(messages[1].capabilities).toMatchObject({ planApi: false }); + planCall(legacy, target, "host.getPlanCapabilities", { connectionId: "c1" }, "legacy"); + await vi.waitFor(() => expect(messages).toHaveLength(3)); + expect(messages[2]).toMatchObject({ id: "legacy", error: "Host plan API is unavailable" }); + }); + 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; @@ -684,7 +883,11 @@ describe("PluginHostBridge", () => { describe("plugin SDK source", () => { interface SdkWindow { - dbxPlugin?: { invoke: (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise }; + dbxPlugin?: { + invoke: (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise; + getPlanCapabilities: (connectionId: string) => Promise; + explainPlan: (request: unknown) => Promise; + }; } function loadSdk(posted: unknown[], initialTheme?: { appearance: "dark" | "light"; tokens: Record }): SdkWindow { @@ -752,4 +955,22 @@ describe("plugin SDK source", () => { expect(params.stamp).toBeInstanceOf(Date); expect(params.nested).toBeInstanceOf(Map); }); + + it("exposes the plan Host API to plugin UI without defaulting the mode", () => { + const posted: unknown[] = []; + const { dbxPlugin } = loadSdk(posted); + + void dbxPlugin!.getPlanCapabilities("c1"); + void dbxPlugin!.explainPlan({ connectionId: "c1", sql: "SELECT 1", mode: "estimated" }); + const requests = posted.filter((message) => (message as { type: string }).type === "request") as { + method: string; + params: Record; + }[]; + + expect(requests.map((request) => request.method)).toEqual(["host.getPlanCapabilities", "host.explainPlan"]); + expect(requests[0].params).toEqual({ connectionId: "c1" }); + // The SDK forwards the mode verbatim: defaulting it would let a plugin reach + // a plan mode it never asked for. + expect(requests[1].params).toEqual({ connectionId: "c1", sql: "SELECT 1", mode: "estimated" }); + }); }); diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index 9f540888d0..8ba569581d 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -1,5 +1,6 @@ import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginUiContribution } from "@/types/database"; 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"; const PLUGIN_MESSAGE_SOURCE = "dbx-plugin"; const HOST_MESSAGE_SOURCE = "dbx-host"; @@ -9,6 +10,8 @@ const MAX_BRIDGE_BINARY_BYTES = 8 * 1024 * 1024; // Distinct from the sidecar binary cap: saved files go straight from the // plugin iframe to disk and never traverse plugin frames. const MAX_BRIDGE_SAVE_BYTES = 512 * 1024 * 1024; +// Mirrors MAX_PLUGIN_PLAN_NAME_CHARS in crates/dbx-core/src/query/plugin_plan.rs. +const MAX_PLUGIN_PLAN_IDENTIFIER_CHARS = 256; /** Structured editor appearance: SQL editor settings that have no CSS-token * carrier (font size is a number, the syntax theme an id). Font families are @@ -58,6 +61,17 @@ export interface PluginHostBridgeApi { openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise | void; /** Explicit user-triggered reconnect of an owned plugin connection (full flow, interactive password prompt allowed). */ reopenConnection?(pluginId: string, connectionId: string): Promise; + /** + * Estimated plan capability metadata for one connection. Requires the plugin + * to declare `host.plans:read`. The host only reads the stored connection + * config; it never connects or probes the server. + */ + getPlanCapabilities?(connectionId: string): Promise; + /** + * Read-only estimated plan acquisition. Requires `host.plans:read`. The host + * generates and owns the EXPLAIN statement; the plugin cannot pass one. + */ + explainPlan?(request: PluginPlanRequest): 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; @@ -177,7 +191,9 @@ export class PluginHostBridge { locale: this.locale, theme: this.theme ? clonePluginData(this.theme) : undefined, permissions: [...(this.plugin.manifest.permissions || [])], - capabilities: { downloadFile: !!this.api.downloadFile }, + // Additive capability advertisement: an older host omits `planApi`, and a + // plugin must treat the absence as "unsupported" rather than probing. + capabilities: { downloadFile: !!this.api.downloadFile, planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan }, context: snapshotPluginWorkbenchContext(this.context), }); } @@ -297,6 +313,17 @@ export class PluginHostBridge { await this.api.openFilesystem(this.plugin.manifest.id, requireProtocolName(input.providerId, "filesystem provider"), isRecord(input.context) ? input.context : undefined); return null; } + if (method === "host.getPlanCapabilities") { + this.requirePermission(PLUGIN_PLAN_PERMISSION); + if (!this.api.getPlanCapabilities) throw new Error("Host plan API is unavailable"); + const input = requireRecord(params, "host.getPlanCapabilities params"); + return this.api.getPlanCapabilities(requirePluginPlanIdentifier(input.connectionId, "connectionId")); + } + if (method === "host.explainPlan") { + this.requirePermission(PLUGIN_PLAN_PERMISSION); + 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.saveFile") { const input = isRecord(params) ? params : {}; // The sandboxed iframe cannot trigger downloads (WKWebView cancels blob @@ -606,6 +633,10 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string { openWorkbench: (contributionId, childContext, options) => request('host.openWorkbench', { contributionId, context: childContext, forceNew: !!(options && options.forceNew) }), openFilesystem: (providerId, childContext) => request('host.openFilesystem', { providerId, context: childContext }), reopenConnection: (connectionId) => request('host.reopenConnection', { connectionId }), + // Estimated plans only: mode must be sent explicitly so a plugin states + // its intent, and the host refuses anything other than "estimated". + getPlanCapabilities: (connectionId) => request('host.getPlanCapabilities', { connectionId }), + explainPlan: (planRequest) => request('host.explainPlan', planRequest), saveFile: (options = {}, data) => { if (data === undefined) return request('host.saveFile', options); if (typeof data === 'string') return request('host.saveFile', { ...(options || {}), dataBase64: data }); @@ -687,6 +718,59 @@ function requireTimeout(value: unknown): number { return Math.min(120_000, Math.max(1, Math.round(value))); } +/** A connection id, database, or schema name: bounded, trimmed, never empty. */ +function requirePluginPlanIdentifier(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 (identifier.length > MAX_PLUGIN_PLAN_IDENTIFIER_CHARS) { + throw new Error(`${label} must be at most ${MAX_PLUGIN_PLAN_IDENTIFIER_CHARS} characters`); + } + return identifier; +} + +function optionalPluginPlanScope(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() ? requirePluginPlanIdentifier(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 + * and an explicit `estimated` mode; anything else is refused here rather than + * forwarded and downgraded. The backend re-checks every one of these bounds. + */ +function requirePluginPlanRequest(input: Record): PluginPlanRequest { + if (input.mode !== "estimated") throw new Error('host.explainPlan serves mode "estimated" only'); + if (typeof input.sql !== "string") throw new Error("host.explainPlan requires sql"); + const sql = input.sql.trim(); + if (!sql) throw new Error("host.explainPlan requires a non-empty sql"); + if (sql.length > MAX_PLUGIN_PLAN_SQL_CHARS) { + throw new Error(`sql must be at most ${MAX_PLUGIN_PLAN_SQL_CHARS} characters`); + } + + const request: PluginPlanRequest = { + connectionId: requirePluginPlanIdentifier(input.connectionId, "connectionId"), + sql, + mode: "estimated", + }; + const database = optionalPluginPlanScope(input.database, "database"); + if (database !== undefined) request.database = database; + const schema = optionalPluginPlanScope(input.schema, "schema"); + if (schema !== undefined) request.schema = schema; + if (input.timeoutMs !== undefined && input.timeoutMs !== null) { + request.timeoutMs = clampPluginPlanTimeout(input.timeoutMs); + } + return request; +} + +/** Pre-clamps to the host ceiling; the backend additionally clamps to the connection's own timeout. */ +function clampPluginPlanTimeout(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("timeoutMs must be a number"); + return Math.min(MAX_PLUGIN_PLAN_TIMEOUT_MS, Math.max(1, Math.round(value))); +} + function base64ToBytes(value: string): ArrayBuffer { const binary = atob(value); const bytes = new Uint8Array(binary.length); diff --git a/apps/desktop/src/types/pluginPlan.ts b/apps/desktop/src/types/pluginPlan.ts new file mode 100644 index 0000000000..5fb27de695 --- /dev/null +++ b/apps/desktop/src/types/pluginPlan.ts @@ -0,0 +1,79 @@ +/** + * Plugin Host API contract for read-only estimated execution plans (#9675). + * + * Kept in `@/types` because three layers share it: the bridge that enforces the + * plugin permission, the backend transport that reaches DBX Core, and the + * plugin SDK surface. The Rust side lives in + * `crates/dbx-core/src/query/plugin_plan.rs`; field names are the serialized + * `camelCase` names of those structs. + * + * The plugin submits its own SQL and a connection reference. The host owns the + * EXPLAIN statement, the connection, the credentials, the driver and the + * timeout, and returns only the raw plan. There is deliberately no way to pass + * an EXPLAIN statement, a driver command or an execution mode. + */ + +/** Permission a plugin must declare to reach `host.getPlanCapabilities` / `host.explainPlan`. */ +export const PLUGIN_PLAN_PERMISSION = "host.plans:read"; + +/** Host-wide ceiling for `timeoutMs`, mirrored from `MAX_PLUGIN_PLAN_TIMEOUT_MS`. */ +export const MAX_PLUGIN_PLAN_TIMEOUT_MS = 60_000; + +/** Host-wide ceiling for `rawPlan`, mirrored from `MAX_PLUGIN_PLAN_BYTES`. */ +export const MAX_PLUGIN_PLAN_BYTES = 4 * 1024 * 1024; + +/** Ceiling for the submitted `sql`, mirrored from `MAX_PLUGIN_PLAN_SQL_CHARS`. */ +export const MAX_PLUGIN_PLAN_SQL_CHARS = 200_000; + +/** + * Warning codes the host may report. `plan_not_json` means the answer was + * reported as `format: "text"` instead of pretending it was parseable JSON; + * the other two mean the host cut the plan to respect its own limits. + */ +export const PLUGIN_PLAN_WARNING = { + notJson: "plan_not_json", + truncated: "plan_truncated", + rowsTruncated: "plan_rows_truncated", +} as const; + +/** How to decode `rawPlan`. */ +export type PluginPlanFormat = "json" | "xml" | "text"; + +/** Only estimated plans are served; actual plans execute the statement. */ +export type PluginPlanMode = "estimated"; + +export interface PluginPlanCapabilities { + /** Dialect the plan will be generated for, in DBX's `db_type` vocabulary. */ + dbType: string; + /** Server product version when DBX already learned it for this connection. */ + dbVersion?: string; + supports: { + /** False when this connection's dialect has no estimated plan path in DBX. */ + estimatedPlan: boolean; + }; + limits: { + /** Tightest timeout the host accepts, after the connection's own limit. */ + maxTimeoutMs: number; + maxPlanBytes: number; + }; +} + +export interface PluginPlanRequest { + connectionId: string; + database?: string; + schema?: string; + sql: string; + mode: PluginPlanMode; + timeoutMs?: number; +} + +export interface PluginPlanResult { + dbType: string; + dbVersion?: string; + format: PluginPlanFormat; + /** A JSON document for `format: "json"`; the plan text otherwise (ShowPlanXML stays a string). */ + rawPlan: unknown; + /** True when the host cut the plan to respect `limits`. */ + truncated: boolean; + warnings: string[]; +} diff --git a/crates/dbx-core/src/ai/agent_explain.rs b/crates/dbx-core/src/ai/agent_explain.rs index 6318c86c51..e1b2fec74e 100644 --- a/crates/dbx-core/src/ai/agent_explain.rs +++ b/crates/dbx-core/src/ai/agent_explain.rs @@ -6,6 +6,11 @@ use crate::connection::{AppState, PoolKind}; use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::query_execution_sql::{is_safe_dameng_autotrace_sql, is_safe_explain_sql_for_database}; +/// Acquires an estimated plan for an agent-backed connection. +/// +/// `timeout_secs` overrides the connection's own query timeout for this call +/// (`None` keeps the connection default). Overrides are host-owned: the plugin +/// Host plan API clamps the value before it gets here. pub async fn get_agent_explain_info_core( state: &AppState, connection_id: &str, @@ -13,13 +18,14 @@ pub async fn get_agent_explain_info_core( schema: Option<&str>, sql: &str, mode: Option<&str>, + timeout_secs: Option, ) -> Result { let mode = mode.unwrap_or("explain"); let (database_type, timeout_secs) = { let configs = state.configs.read().await; let config = configs.get(connection_id).ok_or_else(|| "Connection config not found".to_string())?.clone(); let database_type = explain_database_type(&config); - let timeout_secs = config.effective_query_timeout_secs(); + let timeout_secs = timeout_secs.unwrap_or_else(|| config.effective_query_timeout_secs()); (database_type, timeout_secs) }; if !is_safe_agent_explain_sql(sql, mode, database_type) { @@ -68,7 +74,12 @@ pub async fn get_agent_explain_info_core( decode_agent_explain_result(result) } -fn explain_database_type(config: &ConnectionConfig) -> DatabaseType { +/// Resolves the dialect an EXPLAIN must be generated for. A custom JDBC +/// connection is only known to be Oracle when its configuration says so; every +/// other custom JDBC connection stays `Jdbc` and therefore gets no plan +/// acquisition. Shared with the plugin Host plan API so both paths agree on +/// which dialects are treatable. +pub(crate) fn explain_database_type(config: &ConnectionConfig) -> DatabaseType { if config.db_type != DatabaseType::Jdbc { return config.db_type; } @@ -248,6 +259,7 @@ mod tests { Some("SYSTEM"), "UPDATE T SET C = 1", Some("explain"), + None, ) .await .unwrap(); diff --git a/crates/dbx-core/src/ai/agent_tools.rs b/crates/dbx-core/src/ai/agent_tools.rs index 284480d52c..03483d1990 100644 --- a/crates/dbx-core/src/ai/agent_tools.rs +++ b/crates/dbx-core/src/ai/agent_tools.rs @@ -1180,6 +1180,7 @@ async fn execute_explain_query( default_schema, sql, Some("explain"), + None, ) .await { diff --git a/crates/dbx-core/src/connection/mod.rs b/crates/dbx-core/src/connection/mod.rs index 010313d561..9005c7dc34 100644 --- a/crates/dbx-core/src/connection/mod.rs +++ b/crates/dbx-core/src/connection/mod.rs @@ -1364,6 +1364,25 @@ impl AppState { inspect(&connections.pools) } + /// Whether DBX currently holds a pool for `connection_id`, i.e. the + /// connection is open right now. + /// + /// A saved config proves nothing on its own: a disconnected connection keeps + /// its config while every one of its pools has been drained. The registry is + /// the only state that answers "is this connection open", so callers that + /// must not connect on a user's behalf gate on this instead of on + /// [`Self::configs`]. + /// + /// Deliberately a pure registry read: it never calls + /// `get_or_create_pool`, so checking the state cannot itself open the + /// connection. Ownership uses the same key convention as + /// `drain_connection_pools` — the connection id, optionally followed by `:` + /// and the database/catalog/role/session suffix that `base_pool_key_for` and + /// its session-scoped variant build. + pub async fn is_connection_open(&self, connection_id: &str) -> bool { + self.connections.read().await.keys().any(|key| pool_key_belongs_to_connection(key, connection_id)) + } + /// 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 { @@ -5989,15 +6008,24 @@ fn is_manual_transaction_pool_key(pool_key: &str) -> bool { pool_key.contains(":session:manual-txn-") } +/// Whether `pool_key` names a pool owned by `connection_id`. +/// +/// Every pool key starts with its connection id and appends `:` plus the +/// database, catalog, role, or session suffix, so the separator is what keeps +/// `conn` from matching a `conn-2` pool. Used by +/// [`AppState::is_connection_open`] and [`config_for_pool_key`]; +/// `drain_connection_pools` filters on the same convention. +fn pool_key_belongs_to_connection(pool_key: &str, connection_id: &str) -> bool { + pool_key.strip_prefix(connection_id).is_some_and(|rest| rest.is_empty() || rest.starts_with(':')) +} + pub(crate) fn config_for_pool_key<'a>( pool_key: &str, configs: &'a HashMap, ) -> Option<&'a ConnectionConfig> { configs .iter() - .filter(|(connection_id, _)| { - pool_key.strip_prefix(connection_id.as_str()).is_some_and(|rest| rest.is_empty() || rest.starts_with(':')) - }) + .filter(|(connection_id, _)| pool_key_belongs_to_connection(pool_key, connection_id)) .max_by_key(|(connection_id, _)| connection_id.len()) .map(|(_, config)| config) } @@ -8805,6 +8833,41 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + /// The plan Host API gates on this, so it has to be exactly "DBX holds a pool + /// for this connection": no pool means closed, and the read must not create + /// the pool it is checking for. + #[tokio::test] + async fn connection_is_open_only_while_one_of_its_pools_exists() { + let (state, dir) = test_app_state().await; + let pool = crate::db::sqlite::connect_path(":memory:").await.unwrap(); + + assert!(!state.is_connection_open("conn").await); + assert!(state.with_connection_pools(|pools| pools.is_empty()).await, "the check must not create a pool"); + + for pool_key in ["conn", "conn:analytics", "conn:analytics:catalog:app", "conn:analytics:session:tab-1"] { + state.update_connection_pools(|connections| connections.clear()).await; + state + .update_connection_pools(|connections| { + connections.insert(pool_key.to_string(), PoolKind::Sqlite(pool.clone())) + }) + .await; + assert!(state.is_connection_open("conn").await, "{pool_key} belongs to conn"); + } + + // A sibling id that merely starts with the same characters is another + // connection, and draining one must not report the other as open. + state.update_connection_pools(|connections| connections.clear()).await; + state + .update_connection_pools(|connections| { + connections.insert("conn-2:analytics".to_string(), PoolKind::Sqlite(pool)) + }) + .await; + assert!(!state.is_connection_open("conn").await); + assert!(state.is_connection_open("conn-2").await); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn connection_slot_recovery_waits_for_agent_metadata_and_confirms_close() { let (state, dir) = test_app_state().await; diff --git a/crates/dbx-core/src/query/mod.rs b/crates/dbx-core/src/query/mod.rs index d4d7be3de5..69edcf3607 100644 --- a/crates/dbx-core/src/query/mod.rs +++ b/crates/dbx-core/src/query/mod.rs @@ -2,6 +2,7 @@ pub mod document_ops; pub mod hbase_ops; pub mod mongo_ops; pub mod object_cache; +pub mod plugin_plan; pub mod query_cancel; pub mod redis_ops; pub mod two_phase_commit; diff --git a/crates/dbx-core/src/query/plugin_plan.rs b/crates/dbx-core/src/query/plugin_plan.rs new file mode 100644 index 0000000000..230384a539 --- /dev/null +++ b/crates/dbx-core/src/query/plugin_plan.rs @@ -0,0 +1,854 @@ +//! Read-only estimated execution plan acquisition for the plugin Host API. +//! +//! A plugin never builds a plan statement and never reaches a driver: it +//! submits the original SQL plus a connection reference, and the host resolves +//! the connection, generates the statement through +//! [`build_explain_sql`](crate::query_execution_sql::build_explain_sql), runs the +//! same read-only safety gate DBX uses for its own plan view, and executes it +//! with the host's own connection, credentials, driver, and timeout. The plugin +//! only ever receives the raw plan. +//! +//! Only *estimated* plans are reachable from here. `analyze` is never set, so no +//! request can turn into `EXPLAIN ANALYZE` / `SET STATISTICS XML`, and the +//! caller's statement is never executed — it is only planned. +//! +//! The connection must already be open. Both entry points require a live pool +//! for the connection (`require_open_connection`); a saved-but-disconnected +//! connection is rejected rather than dialled with the stored credentials, so +//! `host.plans:read` can never make DBX connect on a plugin's behalf. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::agent_explain::{explain_database_type, get_agent_explain_info_core}; +use crate::connection::AppState; +use crate::db::QueryResult; +use crate::models::connection::{ConnectionConfig, DatabaseType}; +use crate::query_execution_sql::{ + build_explain_sql, estimated_plan_format, supports_explain_plan, EstimatedPlanFormat, ExplainFormat, + ExplainSqlOptions, +}; + +use super::{execute_sql_statement_with_options_typed, QueryExecutionMode, QueryExecutionOptions}; + +/// The only plan mode this API serves. Actual plans execute the statement, so an +/// unknown mode is rejected outright rather than silently downgraded. +pub const PLUGIN_PLAN_MODE_ESTIMATED: &str = "estimated"; +/// The agent-runtime mode that asks for an estimated plan. Kept separate from +/// the Host API `mode` vocabulary: `autotrace` (Dameng actual plan) is +/// deliberately never requested here. +const AGENT_EXPLAIN_MODE: &str = "explain"; + +/// Host-wide ceiling for a plugin-requested `timeoutMs`; reported as +/// `limits.maxTimeoutMs`. A connection's own query timeout can clamp it further. +pub const MAX_PLUGIN_PLAN_TIMEOUT_MS: u64 = 60_000; +/// Host-wide ceiling for the serialized plan; reported as `limits.maxPlanBytes`. +pub const MAX_PLUGIN_PLAN_BYTES: usize = 4 * 1024 * 1024; +/// Bound on the submitted SQL. Well inside the plugin bridge payload cap so a +/// single plan request always fits one round trip. +pub const MAX_PLUGIN_PLAN_SQL_CHARS: usize = 200_000; + +const MAX_PLUGIN_PLAN_NAME_CHARS: usize = 256; +/// Rows the plan statement may return. OceanBase Oracle returns one line of the +/// JSON document per row and text plans can be long, so this stays generous +/// while still bounding the driver-side result. +const PLUGIN_PLAN_MAX_ROWS: usize = 20_000; +/// The server answered with something that is neither JSON nor expected text. +const WARNING_PLAN_NOT_JSON: &str = "plan_not_json"; +/// The plan text was cut to fit `limits.maxPlanBytes`. +const WARNING_PLAN_TRUNCATED: &str = "plan_truncated"; +/// The driver stopped collecting plan rows at `PLUGIN_PLAN_MAX_ROWS`. +const WARNING_PLAN_ROWS_TRUNCATED: &str = "plan_rows_truncated"; + +/// The one error both plan entry points return when the connection is not open. +/// Shared so `host.getPlanCapabilities` and `host.explainPlan` agree: a closed +/// connection rejects the capability probe instead of succeeding and leaving +/// the plan request to fail later. +const CONNECTION_NOT_OPEN_ERROR: &str = "Connection is not open"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlanPayload { + format: EstimatedPlanFormat, + raw_plan: Value, + truncated: bool, + warnings: Vec, +} + +/// A plugin's request for an estimated plan. The field set is exactly what the +/// Host API accepts; there is deliberately no way to pass an `EXPLAIN` +/// statement, a driver command, or an execution mode. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPlanRequest { + 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 sql: String, + /// Plan mode. Only `"estimated"` is served. + pub mode: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +/// What the current host and connection can do, so a plugin can degrade instead +/// of probing with real requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPlanCapabilities { + pub db_type: String, + /// Server product version when DBX already learned it for this connection. + /// Absent when unknown — the probe is never run on the plugin's behalf. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub db_version: Option, + pub supports: PluginPlanSupports, + pub limits: PluginPlanLimits, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPlanSupports { + pub estimated_plan: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPlanLimits { + pub max_timeout_ms: u64, + pub max_plan_bytes: usize, +} + +/// The raw estimated plan and everything a plugin needs to decode it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPlanResult { + pub db_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub db_version: Option, + pub format: EstimatedPlanFormat, + /// The plan itself: a JSON document for `format: "json"`, otherwise the plan + /// text (ShowPlanXML stays a string). + pub raw_plan: Value, + /// True when the host cut the plan to respect its own limits. + pub truncated: bool, + pub warnings: Vec, +} + +/// Reports what the plugin may expect from this connection without connecting +/// to it, running any SQL, or reading anything beyond the stored config. The +/// connection must already be open, so a closed one is refused here rather than +/// after a plan request has already been prepared. +pub async fn plugin_plan_capabilities(state: &AppState, connection_id: &str) -> Result { + let connection_id = require_connection_id(connection_id)?; + let config = connection_config(state, connection_id).await?; + require_open_connection(state, connection_id).await?; + let database_type = explain_database_type(&config); + Ok(PluginPlanCapabilities { + db_type: database_type.as_str().to_string(), + db_version: database_version(&config), + supports: PluginPlanSupports { estimated_plan: supports_explain_plan(Some(database_type)) }, + limits: PluginPlanLimits { + max_timeout_ms: plugin_plan_timeout_ceiling_ms(&config), + max_plan_bytes: MAX_PLUGIN_PLAN_BYTES, + }, + }) +} + +/// Acquires the estimated plan for `request.sql`. +/// +/// The statement is validated by the same read-only gate DBX uses for its own +/// plan view, the `EXPLAIN` text is generated by the host (never by the +/// caller), and execution happens on the host's connection with the host's +/// timeout. Nothing here can execute the caller's statement. +pub async fn explain_estimated_plan(state: &AppState, request: PluginPlanRequest) -> Result { + let request = validate_plugin_plan_request(request)?; + let config = connection_config(state, &request.connection_id).await?; + require_open_connection(state, &request.connection_id).await?; + let database_type = explain_database_type(&config); + if !supports_explain_plan(Some(database_type)) { + return Err(unsupported_dialect_message(database_type)); + } + + let timeout_secs = plugin_plan_timeout_secs(request.timeout_ms, &config); + let (plan_text, rows_truncated) = if uses_driver_native_plan(database_type) { + // Dameng and Oracle hand back a native plan listing from their driver; + // Oracle's `EXPLAIN PLAN FOR` alone only fills `PLAN_TABLE`. This reuses + // the core the AI and command explain paths already use, pinned to + // `mode = "explain"` so Dameng autotrace stays unreachable. + let text = get_agent_explain_info_core( + state, + &request.connection_id, + request.database.as_deref(), + request.schema.as_deref(), + &request.sql, + Some(AGENT_EXPLAIN_MODE), + Some(timeout_secs), + ) + .await?; + (text, false) + } else { + native_estimated_plan(state, &request, database_type, timeout_secs).await? + }; + + let plan_text = non_empty_plan_text(plan_text)?; + let mut payload = finalize_plan_payload(database_type, plan_text)?; + if rows_truncated { + payload.truncated = true; + payload.warnings.push(WARNING_PLAN_ROWS_TRUNCATED.to_string()); + } + + Ok(PluginPlanResult { + db_type: database_type.as_str().to_string(), + db_version: database_version(&config), + format: payload.format, + raw_plan: payload.raw_plan, + truncated: payload.truncated, + warnings: payload.warnings, + }) +} + +/// Dialects whose plan comes from the driver/oracle-side native plan listing +/// instead of a generated `EXPLAIN` statement. +fn uses_driver_native_plan(database_type: DatabaseType) -> bool { + matches!(database_type, DatabaseType::Dameng | DatabaseType::Oracle) +} + +async fn native_estimated_plan( + state: &AppState, + request: &PluginPlanRequest, + database_type: DatabaseType, + timeout_secs: u64, +) -> Result<(String, bool), String> { + let built = build_explain_sql(ExplainSqlOptions { + database_type: Some(database_type), + // Estimated only: `analyze` is exactly what turns this into a statement + // that runs the caller's SQL. + analyze: None, + format: Some(ExplainFormat::Json), + sql: request.sql.clone(), + }); + if !built.ok { + return Err(explain_build_error(built.reason.as_deref(), database_type)); + } + let Some(explain_sql) = built.sql else { + return Err("The host did not build an EXPLAIN statement".to_string()); + }; + + if database_type == DatabaseType::SqlServer { + return sqlserver_estimated_plan(state, request, timeout_secs).await; + } + + let database = request.database.as_deref().unwrap_or_default(); + let result = execute_sql_statement_with_options_typed( + state, + &request.connection_id, + database, + &explain_sql, + request.schema.as_deref(), + None, + QueryExecutionOptions { + max_rows: Some(PLUGIN_PLAN_MAX_ROWS), + timeout_secs: Some(timeout_secs), + ..Default::default() + }, + ) + .await + .map_err(|error| error.to_string())?; + + Ok((join_result_text(&result), result.truncated)) +} + +/// `SET SHOWPLAN_XML ON` is session state, so the toggle, the planned statement, +/// and the toggle-off must share one client session. DBX's own explain view +/// does the same with a dedicated explain session; the plugin path keeps the +/// session short-lived and discards it afterwards. +async fn sqlserver_estimated_plan( + state: &AppState, + request: &PluginPlanRequest, + timeout_secs: u64, +) -> Result<(String, bool), String> { + let client_session_id = format!("plugin-plan-{}", uuid::Uuid::new_v4()); + let database = request.database.as_deref().unwrap_or_default(); + let session_database = (!database.is_empty()).then_some(database); + let options = |execution_mode: QueryExecutionMode| QueryExecutionOptions { + max_rows: Some(PLUGIN_PLAN_MAX_ROWS), + client_session_id: Some(client_session_id.clone()), + // ShowPlan capture must not be distorted by result-set probing or query + // rewriting, which is what the plain execution mode deliberately does. + execution_mode, + timeout_secs: Some(timeout_secs), + ..Default::default() + }; + + let capture = async { + execute_sql_statement_with_options_typed( + state, + &request.connection_id, + database, + "SET SHOWPLAN_XML ON;", + request.schema.as_deref(), + None, + options(QueryExecutionMode::Simple), + ) + .await + .map_err(|error| error.to_string())?; + execute_sql_statement_with_options_typed( + state, + &request.connection_id, + database, + &request.sql, + request.schema.as_deref(), + None, + options(QueryExecutionMode::Simple), + ) + .await + .map_err(|error| error.to_string()) + } + .await; + + // The session pool is dropped immediately below, so a failed toggle-off can + // never leave ShowPlan capture enabled for another caller's session. + let _ = execute_sql_statement_with_options_typed( + state, + &request.connection_id, + database, + "SET SHOWPLAN_XML OFF;", + request.schema.as_deref(), + None, + options(QueryExecutionMode::Simple), + ) + .await; + let _ = state.close_client_session_pool(&request.connection_id, session_database, &client_session_id).await; + + let result = capture?; + let plan = sqlserver_showplan_xml(&result) + .ok_or_else(|| "SQL Server did not return a ShowPlan XML estimated plan".to_string())?; + Ok((plan, result.truncated)) +} + +/// Extracts the `` document from the captured result, mirroring how +/// DBX's own plan view locates it: the driver may report other cells alongside. +fn sqlserver_showplan_xml(result: &QueryResult) -> Option { + result + .rows + .iter() + .flatten() + .find(|cell| matches!(cell, Value::String(text) if text.contains(" String { + result.rows.iter().flatten().map(cell_text).collect::>().join("\n") +} + +fn cell_text(cell: &Value) -> String { + match cell { + Value::Null => String::new(), + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +fn non_empty_plan_text(text: String) -> Result { + if text.trim().is_empty() { + return Err("The database returned an empty estimated plan".to_string()); + } + Ok(text) +} + +/// Decodes the acquired text into the shape advertised by `format`, downgrades +/// an unparseable JSON answer to text instead of lying about it, and enforces +/// the payload cap. +fn finalize_plan_payload(database_type: DatabaseType, text: String) -> Result { + let mut format = estimated_plan_format(Some(database_type)); + let mut warnings = Vec::new(); + let mut raw_plan = Value::String(text); + + if format == EstimatedPlanFormat::Json { + match serde_json::from_str::(raw_plan.as_str().unwrap_or_default()) { + Ok(parsed) => raw_plan = parsed, + Err(_) => { + warnings.push(WARNING_PLAN_NOT_JSON.to_string()); + format = EstimatedPlanFormat::Text; + } + } + } + + let encoded = serde_json::to_vec(&raw_plan).map_err(|error| error.to_string())?; + if encoded.len() <= MAX_PLUGIN_PLAN_BYTES { + return Ok(PlanPayload { format, raw_plan, truncated: false, warnings }); + } + + // Only a text plan can be cut without corrupting its own encoding. A JSON + // document has no meaningful partial form, so an oversized one fails rather + // than turning into something the plugin cannot parse. + let Value::String(text) = &raw_plan else { + return Err(plan_too_large_message()); + }; + let Some(truncated) = truncate_json_string_to_bytes(text, MAX_PLUGIN_PLAN_BYTES) else { + return Err(plan_too_large_message()); + }; + warnings.push(WARNING_PLAN_TRUNCATED.to_string()); + Ok(PlanPayload { format, raw_plan: Value::String(truncated), truncated: true, warnings }) +} + +fn plan_too_large_message() -> String { + format!("The estimated plan exceeds the {MAX_PLUGIN_PLAN_BYTES} byte host limit") +} + +/// Cuts `text` so that its JSON encoding fits `limit` bytes. Escaping only ever +/// grows the payload, so the search shrinks until the encoded length fits. +fn truncate_json_string_to_bytes(text: &str, limit: usize) -> Option { + let mut end = limit.min(text.len()); + while end > 0 { + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + if end == 0 { + return None; + } + let candidate = &text[..end]; + let fits = + serde_json::to_vec(&Value::String(candidate.to_string())).is_ok_and(|encoded| encoded.len() <= limit); + if fits { + return Some(candidate.to_string()); + } + end -= (end / 8).max(1); + } + None +} + +fn explain_build_error(reason: Option<&str>, database_type: DatabaseType) -> String { + match reason { + Some("unsupported") => unsupported_dialect_message(database_type), + Some("empty") => "Plugin plan requests need a non-empty sql".to_string(), + // `build_explain_sql` runs DBX's own read-only plan gate; a rejection + // here is the security boundary doing its job, not a host defect. The + // message stays generic so a plugin cannot use it to probe the gate. + _ => "The requested statement is not safe to plan".to_string(), + } +} + +fn unsupported_dialect_message(database_type: DatabaseType) -> String { + format!("Estimated execution plans are not available for '{}' connections", database_type.as_str()) +} + +fn database_version(config: &ConnectionConfig) -> Option { + config.database_info.as_ref().and_then(|info| info.product_version.clone()) +} + +async fn connection_config(state: &AppState, connection_id: &str) -> Result { + let configs = state.configs.read().await; + configs.get(connection_id).cloned().ok_or_else(|| "Connection config not found".to_string()) +} + +/// Enforces the boundary both plan entry points document: a plugin may plan on +/// a connection DBX already has open, never on a saved config alone. +/// +/// `state.configs` holds every *saved* connection, and a disconnected one keeps +/// its config, so the config table cannot answer this. Only the pool registry +/// can, and it answers without connecting: a saved-but-closed connection is +/// rejected here instead of being dialled with the stored credentials. Runs +/// after [`connection_config`] so an unknown id still reports itself as unknown. +async fn require_open_connection(state: &AppState, connection_id: &str) -> Result<(), String> { + if state.is_connection_open(connection_id).await { + return Ok(()); + } + Err(CONNECTION_NOT_OPEN_ERROR.to_string()) +} + +fn require_connection_id(connection_id: &str) -> Result<&str, String> { + let trimmed = connection_id.trim(); + if trimmed.is_empty() || trimmed.chars().count() > MAX_PLUGIN_PLAN_NAME_CHARS { + return Err("Plugin plan requests need a valid connectionId".to_string()); + } + Ok(trimmed) +} + +/// Rejects anything the Host API does not serve. This is the plugin-facing +/// boundary, so it runs before the connection is even resolved. +fn validate_plugin_plan_request(mut request: PluginPlanRequest) -> Result { + request.connection_id = require_connection_id(&request.connection_id)?.to_string(); + if request.mode != PLUGIN_PLAN_MODE_ESTIMATED { + return Err(format!( + "Plugin plan requests support mode '{}' only; '{}' is not available", + PLUGIN_PLAN_MODE_ESTIMATED, request.mode + )); + } + let sql = request.sql.trim(); + if sql.is_empty() { + return Err("Plugin plan requests need a non-empty sql".to_string()); + } + if sql.chars().count() > MAX_PLUGIN_PLAN_SQL_CHARS { + return Err(format!("Plugin plan sql exceeds {MAX_PLUGIN_PLAN_SQL_CHARS} characters")); + } + request.sql = sql.to_string(); + request.database = normalize_plan_scope(request.database, "database")?; + request.schema = normalize_plan_scope(request.schema, "schema")?; + Ok(request) +} + +fn normalize_plan_scope(value: Option, name: &str) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + if trimmed.chars().count() > MAX_PLUGIN_PLAN_NAME_CHARS { + return Err(format!("Plugin plan {name} is too long")); + } + Ok(Some(trimmed.to_string())) +} + +/// The largest timeout a plugin may request for this connection: the +/// connection's own query timeout, never more than the host-wide ceiling. A +/// connection configured with `0` ("no limit") still gets the host ceiling. +pub fn plugin_plan_timeout_ceiling_ms(config: &ConnectionConfig) -> u64 { + let connection_secs = config.effective_query_timeout_secs(); + if connection_secs == 0 { + return MAX_PLUGIN_PLAN_TIMEOUT_MS; + } + connection_secs.saturating_mul(1000).min(MAX_PLUGIN_PLAN_TIMEOUT_MS) +} + +/// Clamps a request to the connection ceiling. Execution granularity is +/// seconds, so the value rounds up rather than silently shortening the request. +pub fn plugin_plan_timeout_secs(timeout_ms: Option, config: &ConnectionConfig) -> u64 { + let ceiling = plugin_plan_timeout_ceiling_ms(config); + let requested = timeout_ms.map_or(ceiling, |timeout_ms| timeout_ms.clamp(1, ceiling)); + requested.div_ceil(1000).max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::connection::PoolKind; + use crate::storage::Storage; + + fn config(db_type: DatabaseType, query_timeout_secs: u64) -> ConnectionConfig { + serde_json::from_value(serde_json::json!({ + "id": "conn-1", + "name": "Connection", + "db_type": db_type.as_str(), + "host": "127.0.0.1", + "port": 5432, + "username": "user", + "password": "secret", + "query_timeout_secs": query_timeout_secs + })) + .unwrap() + } + + /// A saved config with a different id, so one state can hold several. + fn config_for(db_type: DatabaseType, id: &str, query_timeout_secs: u64) -> ConnectionConfig { + let mut config = config(db_type, query_timeout_secs); + config.id = id.to_string(); + config + } + + /// A state holding saved configs but no pools: exactly the "saved but + /// disconnected" situation the plan boundary has to refuse. + async fn saved_connection_state(configs: &[ConnectionConfig]) -> (AppState, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let storage = Storage::open(&dir.path().join("storage.db")).await.expect("open storage"); + let state = AppState::new_with_plugin_dir(storage, dir.path().join("plugins")); + { + let mut stored = state.configs.write().await; + for config in configs { + stored.insert(config.id.clone(), config.clone()); + } + } + (state, dir) + } + + /// Opens `connection_id` the way DBX does: a pool in the registry. The pool + /// kind is irrelevant here because the plan gate only reads pool keys. + async fn open_connection(state: &AppState, pool_key: &str) { + let pool = PoolKind::Redis(std::sync::Arc::new(crate::db::redis_driver::redis_connection_test_stub())); + state.update_connection_pools(|connections| connections.insert(pool_key.to_string(), pool)).await; + } + + fn request(mode: &str, sql: &str) -> PluginPlanRequest { + PluginPlanRequest { + connection_id: "conn-1".to_string(), + database: None, + schema: None, + sql: sql.to_string(), + mode: mode.to_string(), + timeout_ms: None, + } + } + + #[test] + fn accepts_only_the_estimated_mode() { + assert!(validate_plugin_plan_request(request(PLUGIN_PLAN_MODE_ESTIMATED, "SELECT 1")).is_ok()); + for mode in ["actual", "analyze", "Estimated", "estimated ", "autotrace", ""] { + let error = validate_plugin_plan_request(request(mode, "SELECT 1")).unwrap_err(); + assert!(error.contains("mode"), "mode '{mode}' must be rejected: {error}"); + } + } + + #[test] + fn rejects_missing_connection_and_empty_sql() { + let mut empty_connection = request(PLUGIN_PLAN_MODE_ESTIMATED, "SELECT 1"); + empty_connection.connection_id = " ".to_string(); + assert!(validate_plugin_plan_request(empty_connection).unwrap_err().contains("connectionId")); + + assert!(validate_plugin_plan_request(request(PLUGIN_PLAN_MODE_ESTIMATED, " \n ")) + .unwrap_err() + .contains("sql")); + } + + #[test] + fn rejects_oversized_inputs() { + let oversized_sql = format!("SELECT '{}'", "a".repeat(MAX_PLUGIN_PLAN_SQL_CHARS)); + assert!(validate_plugin_plan_request(request(PLUGIN_PLAN_MODE_ESTIMATED, &oversized_sql)) + .unwrap_err() + .contains("characters")); + + let mut oversized_database = request(PLUGIN_PLAN_MODE_ESTIMATED, "SELECT 1"); + oversized_database.database = Some("d".repeat(MAX_PLUGIN_PLAN_NAME_CHARS + 1)); + assert!(validate_plugin_plan_request(oversized_database).unwrap_err().contains("database")); + + let mut oversized_connection = request(PLUGIN_PLAN_MODE_ESTIMATED, "SELECT 1"); + oversized_connection.connection_id = "c".repeat(MAX_PLUGIN_PLAN_NAME_CHARS + 1); + assert!(validate_plugin_plan_request(oversized_connection).unwrap_err().contains("connectionId")); + } + + #[test] + fn normalizes_scope_and_sql_whitespace() { + let mut scoped = request(PLUGIN_PLAN_MODE_ESTIMATED, " SELECT 1 "); + scoped.database = Some(" app ".to_string()); + scoped.schema = Some(" ".to_string()); + + let validated = validate_plugin_plan_request(scoped).unwrap(); + assert_eq!(validated.sql, "SELECT 1"); + assert_eq!(validated.database.as_deref(), Some("app")); + assert_eq!(validated.schema, None); + } + + #[test] + fn clamps_timeouts_to_the_connection_and_host_ceiling() { + let unlimited = config(DatabaseType::Postgres, 0); + assert_eq!(plugin_plan_timeout_ceiling_ms(&unlimited), MAX_PLUGIN_PLAN_TIMEOUT_MS); + assert_eq!(plugin_plan_timeout_secs(None, &unlimited), 60); + assert_eq!(plugin_plan_timeout_secs(Some(1), &unlimited), 1); + assert_eq!(plugin_plan_timeout_secs(Some(u64::MAX), &unlimited), 60); + assert_eq!(plugin_plan_timeout_secs(Some(0), &unlimited), 1); + + let connection_capped = config(DatabaseType::Postgres, 5); + assert_eq!(plugin_plan_timeout_ceiling_ms(&connection_capped), 5_000); + assert_eq!(plugin_plan_timeout_secs(None, &connection_capped), 5); + assert_eq!(plugin_plan_timeout_secs(Some(30_000), &connection_capped), 5); + assert_eq!(plugin_plan_timeout_secs(Some(1_500), &connection_capped), 2); + + let hour_long = config(DatabaseType::Postgres, 3_600); + assert_eq!(plugin_plan_timeout_ceiling_ms(&hour_long), MAX_PLUGIN_PLAN_TIMEOUT_MS); + assert_eq!(plugin_plan_timeout_secs(Some(45_000), &hour_long), 45); + } + + #[test] + fn parses_json_plans_and_downgrades_other_payloads_to_text() { + let payload = + finalize_plan_payload(DatabaseType::Postgres, r#"[{"Plan": {"Node Type": "Seq Scan"}}]"#.to_string()) + .unwrap(); + assert_eq!(payload.format, EstimatedPlanFormat::Json); + assert_eq!(payload.raw_plan[0]["Plan"]["Node Type"], Value::String("Seq Scan".to_string())); + assert!(!payload.truncated); + assert!(payload.warnings.is_empty()); + + let payload = finalize_plan_payload(DatabaseType::Postgres, "Seq Scan on users".to_string()).unwrap(); + assert_eq!(payload.format, EstimatedPlanFormat::Text); + assert_eq!(payload.raw_plan, Value::String("Seq Scan on users".to_string())); + assert_eq!(payload.warnings, vec![WARNING_PLAN_NOT_JSON.to_string()]); + } + + #[test] + fn keeps_text_and_xml_plans_as_strings() { + let text = finalize_plan_payload(DatabaseType::Oracle, "1 #NSET2: [0, 1, 0]".to_string()).unwrap(); + assert_eq!(text.format, EstimatedPlanFormat::Text); + assert_eq!(text.raw_plan, Value::String("1 #NSET2: [0, 1, 0]".to_string())); + + let xml = + finalize_plan_payload(DatabaseType::SqlServer, "".to_string()) + .unwrap(); + assert_eq!(xml.format, EstimatedPlanFormat::Xml); + assert_eq!(xml.raw_plan, Value::String("".to_string())); + assert!(xml.warnings.is_empty()); + } + + #[test] + fn truncates_oversized_text_plans_and_rejects_oversized_json_plans() { + let oversized = "x".repeat(MAX_PLUGIN_PLAN_BYTES + 1_024); + let payload = finalize_plan_payload(DatabaseType::Doris, oversized.clone()).unwrap(); + assert!(payload.truncated); + assert_eq!(payload.warnings, vec![WARNING_PLAN_TRUNCATED.to_string()]); + let Value::String(text) = &payload.raw_plan else { + panic!("a truncated text plan stays a string"); + }; + assert!(text.len() < oversized.len()); + assert!(serde_json::to_vec(&payload.raw_plan).unwrap().len() <= MAX_PLUGIN_PLAN_BYTES); + + // A JSON document must not be handed over half-parsed. + let oversized_json = format!(r#"[{{"Plan": "{}"}}]"#, "y".repeat(MAX_PLUGIN_PLAN_BYTES + 1_024)); + let error = finalize_plan_payload(DatabaseType::Postgres, oversized_json).unwrap_err(); + assert!(error.contains("host limit"), "{error}"); + } + + #[test] + fn truncation_respects_utf8_and_escaping() { + // Every character encodes as two JSON bytes plus the quote/escape pair. + let oversized = "\"".repeat(MAX_PLUGIN_PLAN_BYTES); + let truncated = truncate_json_string_to_bytes(&oversized, 1_024).unwrap(); + assert!(serde_json::to_vec(&Value::String(truncated)).unwrap().len() <= 1_024); + + let multibyte = "計".repeat(2_000); + let truncated = truncate_json_string_to_bytes(&multibyte, 64).unwrap(); + assert!(multibyte.starts_with(&truncated)); + assert!(truncated.is_char_boundary(truncated.len())); + } + + #[test] + fn rejects_empty_plans() { + assert!(non_empty_plan_text(String::new()).is_err()); + assert!(non_empty_plan_text(" \n ".to_string()).is_err()); + assert_eq!(non_empty_plan_text("Seq Scan".to_string()).unwrap(), "Seq Scan"); + } + + #[test] + fn joins_result_rows_into_one_document() { + let result: QueryResult = serde_json::from_value(serde_json::json!({ + "columns": ["Query Plan"], + "rows": [["{"], [" \"query_block\": {}"], ["}"]], + "affected_rows": 0, + "execution_time_ms": 1 + })) + .unwrap(); + + assert_eq!(join_result_text(&result), "{\n \"query_block\": {}\n}"); + assert!(serde_json::from_str::(&join_result_text(&result)).is_ok()); + } + + #[test] + fn extracts_showplan_xml_from_any_cell() { + let result: QueryResult = serde_json::from_value(serde_json::json!({ + "columns": ["note", "plan"], + "rows": [["SHOWPLAN", ""]], + "affected_rows": 0, + "execution_time_ms": 1 + })) + .unwrap(); + assert_eq!(sqlserver_showplan_xml(&result).as_deref(), Some("")); + + let without_plan: QueryResult = serde_json::from_value(serde_json::json!({ + "columns": ["note"], + "rows": [["no plan"]], + "affected_rows": 0, + "execution_time_ms": 1 + })) + .unwrap(); + assert_eq!(sqlserver_showplan_xml(&without_plan), None); + } + + #[test] + fn plans_native_dialects_through_the_driver_and_the_rest_through_explain_sql() { + assert!(uses_driver_native_plan(DatabaseType::Dameng)); + assert!(uses_driver_native_plan(DatabaseType::Oracle)); + + for database_type in + [DatabaseType::Postgres, DatabaseType::Mysql, DatabaseType::SqlServer, DatabaseType::OceanbaseOracle] + { + assert!(!uses_driver_native_plan(database_type)); + assert!(supports_explain_plan(Some(database_type))); + } + } + + #[test] + fn rejects_unsupported_dialects_and_unsafe_statements() { + let redis = config(DatabaseType::Redis, 0); + assert!(!supports_explain_plan(Some(explain_database_type(&redis)))); + + // `build_explain_sql` is the same gate the plugin path relies on. + for sql in [ + "DROP TABLE users", + "DELETE FROM users", + "INSERT INTO users (id) VALUES (1)", + "UPDATE users SET name = 'x'", + "SELECT 1; DROP TABLE users", + "CREATE INDEX idx ON users (id)", + ] { + assert!( + !build_explain_sql(ExplainSqlOptions { + database_type: Some(DatabaseType::Postgres), + analyze: None, + format: None, + sql: sql.to_string(), + }) + .ok, + "{sql} must not build an estimated plan statement" + ); + } + } + + #[test] + fn reports_capability_limits_and_dialect_without_connecting() { + // `plugin_plan_capabilities` only reads the stored config, so the parts + // that do not need `AppState` are asserted through the shared helpers. + let postgres = config(DatabaseType::Postgres, 30); + assert_eq!(plugin_plan_timeout_ceiling_ms(&postgres), 30_000); + assert_eq!(database_version(&postgres), None); + assert_eq!(explain_database_type(&postgres).as_str(), "postgres"); + } + + /// The boundary the docs promise, enforced in core: a saved config is not an + /// open connection, so a plugin can never make DBX dial stored credentials. + #[tokio::test] + async fn saved_but_disconnected_connection_is_rejected_by_both_plan_calls() { + let postgres = config(DatabaseType::Postgres, 30); + let (state, _dir) = saved_connection_state(std::slice::from_ref(&postgres)).await; + + let error = plugin_plan_capabilities(&state, &postgres.id).await.unwrap_err(); + assert_eq!(error, CONNECTION_NOT_OPEN_ERROR); + + let error = explain_estimated_plan(&state, request(PLUGIN_PLAN_MODE_ESTIMATED, "SELECT 1")).await.unwrap_err(); + assert_eq!(error, CONNECTION_NOT_OPEN_ERROR); + + // The check is a pure read: it must not have opened the connection it + // just refused, and an unknown id must still report itself as unknown. + assert!(state.with_connection_pools(|pools| pools.is_empty()).await); + assert_eq!(plugin_plan_capabilities(&state, "unknown").await.unwrap_err(), "Connection config not found"); + } + + /// The same connection, once DBX holds it open, behaves exactly as before. + #[tokio::test] + async fn an_open_connection_keeps_the_existing_behavior() { + let postgres = config(DatabaseType::Postgres, 30); + let redis = config_for(DatabaseType::Redis, "redis-conn", 0); + let (state, _dir) = saved_connection_state(&[postgres.clone(), redis.clone()]).await; + + // A database-scoped or session-scoped pool is the same open connection. + open_connection(&state, "conn-1:analytics").await; + open_connection(&state, "redis-conn:session:tab-1").await; + + let capabilities = plugin_plan_capabilities(&state, &postgres.id).await.unwrap(); + assert_eq!(capabilities.db_type, "postgres"); + assert!(capabilities.supports.estimated_plan); + assert_eq!(capabilities.limits.max_timeout_ms, 30_000); + + // Redis has no estimated plan path, but the request reaches that dialect + // check instead of the open-connection gate. + let request = + PluginPlanRequest { connection_id: redis.id.clone(), ..request(PLUGIN_PLAN_MODE_ESTIMATED, "SELECT 1") }; + assert_eq!( + explain_estimated_plan(&state, request).await.unwrap_err(), + unsupported_dialect_message(DatabaseType::Redis) + ); + } +} diff --git a/crates/dbx-plugin-runtime/src/plugins/manifest.rs b/crates/dbx-plugin-runtime/src/plugins/manifest.rs index d42fa367f1..036a47ed19 100644 --- a/crates/dbx-plugin-runtime/src/plugins/manifest.rs +++ b/crates/dbx-plugin-runtime/src/plugins/manifest.rs @@ -8,10 +8,12 @@ pub const SUPPORTED_PLUGIN_MANIFEST_VERSION: u32 = 1; /// Host API version the host advertises at `plugin/initialize`. /// /// 1.1 adds the plugin-initiated `host/requestUserInput` method (see -/// `plugins/runtime.rs`). It is additive: 1.0 plugins keep working, and a -/// plugin that wants the capability must check the advertised version (or the -/// `host.requestUserInput` entry in `host.features`) before calling it. -pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.1.0"; +/// `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` +/// entry) before calling it. +pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.2.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; @@ -19,7 +21,8 @@ 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"]; +pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = + &["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read"]; /// Cap the number of `host.network:` entries so a manifest cannot bloat /// the sandbox CSP or enumerate large origin lists. @@ -1482,7 +1485,7 @@ mod tests { use super::{ parse_host_network_permission, resolve_safe_plugin_path, validate_connection_actions, PluginConnectionActionContribution, PluginConnectionProviderContribution, PluginFormFieldBinding, - PluginManifest, + PluginManifest, SUPPORTED_PLUGIN_HOST_API_VERSION, SUPPORTED_PLUGIN_PERMISSIONS, }; #[test] @@ -1544,6 +1547,110 @@ mod tests { assert!(compatibility.errors.iter().any(|error| error.contains("host.network:http://api.vendor.com"))); } + #[test] + fn accepts_host_plans_read_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(); + + let manifest: PluginManifest = serde_json::from_value(serde_json::json!({ + "manifest_version": 1, + "id": "io.dbx.example", + "name": "Example", + "version": "1.0.0", + "publisher": "example", + "engines": { "dbx": ">=0.1.0", "host_api": "^1.0" }, + "entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }, + "permissions": ["host.plans: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"] { + let manifest: PluginManifest = serde_json::from_value(serde_json::json!({ + "manifest_version": 1, + "id": "io.dbx.example", + "name": "Example", + "version": "1.0.0", + "publisher": "example", + "engines": { "dbx": ">=0.1.0", "host_api": "^1.0" }, + "entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }, + "permissions": [permission] + })) + .unwrap(); + let compatibility = manifest.compatibility(dir.path(), "0.1.0"); + assert!(!compatibility.compatible, "{permission} must stay unsupported"); + assert!( + compatibility.errors.iter().any(|error| error.contains(permission)), + "{permission}: {:?}", + compatibility.errors + ); + } + } + + /// The published schema is the editor/CI contract for the same enum; a + /// permission added to one side only would let a manifest pass an editor + /// check and fail installation (or the reverse). + #[test] + fn manifest_schema_permission_enum_matches_supported_permissions() { + let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("plugins") + .join("manifest.schema.json"); + let schema: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&schema_path).unwrap()).unwrap(); + let declared = schema["properties"]["permissions"]["items"]["anyOf"][0]["enum"] + .as_array() + .expect("permissions.items.anyOf[0].enum must be an array") + .iter() + .map(|value| value.as_str().unwrap().to_string()) + .collect::>(); + + 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. + #[test] + fn host_api_advertises_the_floor_a_plan_api_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" + ); + + for requirement in ["^1.0", "^1.1", "^1.2", ">=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"] { + assert!(!host_api_requirement_errors(requirement).is_empty(), "{requirement} must be rejected"); + } + } + + /// A minimal v1 manifest declaring `host.plans:read`, so the compatibility + /// result isolates `engines.host_api`. + fn host_api_requirement_errors(requirement: &str) -> Vec { + 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(); + let manifest: PluginManifest = serde_json::from_value(serde_json::json!({ + "manifest_version": 1, + "id": "io.dbx.example", + "name": "Example", + "version": "1.0.0", + "publisher": "example", + "engines": { "dbx": ">=0.1.0", "host_api": requirement }, + "entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }, + "permissions": ["host.plans:read"] + })) + .unwrap(); + manifest.compatibility(dir.path(), "0.1.0").errors + } + #[test] fn validates_manifest_v1_and_resolves_package_executable() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/dbx-sql/src/query_execution_sql.rs b/crates/dbx-sql/src/query_execution_sql.rs index 191b881b31..313b1288c8 100644 --- a/crates/dbx-sql/src/query_execution_sql.rs +++ b/crates/dbx-sql/src/query_execution_sql.rs @@ -37,6 +37,33 @@ pub struct ExplainSqlBuildResult { pub reason: Option, } +/// Payload encoding of an estimated plan produced by [`build_explain_sql`]. +/// +/// Callers that hand a raw plan to a consumer which cannot inspect the SQL +/// (the plugin Host API) need to know how to decode it. Keep this in sync with +/// the generated statements in [`build_explain_sql`]; +/// `estimated_plan_format_matches_generated_sql` guards the pairing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EstimatedPlanFormat { + /// A JSON document returned as a single cell (PostgreSQL, MySQL, OceanBase Oracle). + Json, + /// A `ShowPlanXML` document returned as text (SQL Server `SET SHOWPLAN_XML`). + Xml, + /// A human-readable plan listing (Dameng, Doris, QuestDB, Oracle). + Text, +} + +impl EstimatedPlanFormat { + pub const fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Xml => "xml", + Self::Text => "text", + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DroppedFilePreviewSqlOptions { @@ -119,6 +146,20 @@ pub fn build_dropped_file_preview_sql(options: DroppedFilePreviewSqlOptions) -> None } +/// How a consumer should decode the estimated plan `build_explain_sql` asks +/// the server for. Dameng and Oracle plans reach DBX through the driver's +/// native plan text rather than a generated `EXPLAIN` statement, and every +/// agent plan is text. +pub fn estimated_plan_format(database_type: Option) -> EstimatedPlanFormat { + match database_type { + Some(DatabaseType::SqlServer) => EstimatedPlanFormat::Xml, + Some(DatabaseType::Dameng | DatabaseType::Questdb | DatabaseType::Doris | DatabaseType::Oracle) => { + EstimatedPlanFormat::Text + } + _ => EstimatedPlanFormat::Json, + } +} + pub fn supports_explain_plan(database_type: Option) -> bool { matches!( database_type, @@ -1282,6 +1323,47 @@ mod tests { } } + #[test] + fn estimated_plan_format_matches_generated_sql() { + // Every dialect `supports_explain_plan` advertises must declare a payload + // format that the statement `build_explain_sql` generates really produces. + for database_type in + DatabaseType::ALL.iter().copied().filter(|database_type| supports_explain_plan(Some(*database_type))) + { + let explain_sql = build_explain_sql(ExplainSqlOptions { + database_type: Some(database_type), + format: None, + analyze: None, + sql: "SELECT 1".to_string(), + }) + .sql + .expect("a supported dialect always builds EXPLAIN SQL"); + + match estimated_plan_format(Some(database_type)) { + EstimatedPlanFormat::Json => assert!( + explain_sql.contains("FORMAT JSON") || explain_sql.contains("FORMAT=JSON"), + "{database_type:?} declares a JSON plan but generated '{explain_sql}'" + ), + EstimatedPlanFormat::Xml => assert!( + explain_sql.contains("SHOWPLAN_XML"), + "{database_type:?} declares an XML plan but generated '{explain_sql}'" + ), + EstimatedPlanFormat::Text => assert!( + explain_sql.starts_with("EXPLAIN ") && !explain_sql.contains("FORMAT"), + "{database_type:?} declares a text plan but generated '{explain_sql}'" + ), + } + } + + assert_eq!(estimated_plan_format(None), EstimatedPlanFormat::Json); + assert_eq!(estimated_plan_format(Some(DatabaseType::Dameng)), EstimatedPlanFormat::Text); + assert_eq!(estimated_plan_format(Some(DatabaseType::Questdb)), EstimatedPlanFormat::Text); + assert_eq!(estimated_plan_format(Some(DatabaseType::Doris)), EstimatedPlanFormat::Text); + assert_eq!(estimated_plan_format(Some(DatabaseType::Oracle)), EstimatedPlanFormat::Text); + assert_eq!(estimated_plan_format(Some(DatabaseType::SqlServer)), EstimatedPlanFormat::Xml); + assert_eq!(estimated_plan_format(Some(DatabaseType::Mysql)), EstimatedPlanFormat::Json); + } + #[test] fn builds_sqlserver_showplan_xml_batches() { let result = build_explain_sql(ExplainSqlOptions { diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 63f70c0af0..09d8e92445 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -567,6 +567,8 @@ async fn main() { .route("/query/build-explain-sql", post(routes::query::build_explain_sql)) .route("/query/build-dropped-file-preview-sql", post(routes::query::build_dropped_file_preview_sql)) .route("/query/get-explain-info", post(routes::query::get_explain_info)) + .route("/query/plugin-plan-capabilities", post(routes::query::get_plugin_plan_capabilities)) + .route("/query/plugin-estimated-plan", post(routes::query::get_plugin_estimated_plan)) .route("/query/build-create-user-sql", post(routes::query::build_create_user_sql)) .route("/query/build-table-select-sql", post(routes::query::build_table_select_sql)) .route("/query/build-database-search-sql", post(routes::query::build_database_search_sql)) diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 7764dda897..d59fb0095a 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -808,6 +808,12 @@ pub struct GetExplainInfoRequest { pub mode: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetPluginPlanCapabilitiesRequest { + pub connection_id: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildCreateUserSqlRequest { @@ -827,12 +833,36 @@ pub async fn get_explain_info( req.schema.as_deref(), &req.sql, req.mode.as_deref(), + None, ) .await .map_err(AppError::from)?; Ok(Json(plan)) } +/// Plugin Host API: what the current host and connection can plan, reported +/// without connecting or running any SQL. +pub async fn get_plugin_plan_capabilities( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let capabilities = dbx_core::query::plugin_plan::plugin_plan_capabilities(&state.app, &req.connection_id) + .await + .map_err(AppError::from)?; + Ok(Json(capabilities)) +} + +/// Plugin Host API: read-only estimated plan acquisition. The request carries +/// the original SQL only; the host generates and owns the EXPLAIN. +pub async fn get_plugin_estimated_plan( + State(state): State>, + Json(request): Json, +) -> Result, AppError> { + let result = + dbx_core::query::plugin_plan::explain_estimated_plan(&state.app, request).await.map_err(AppError::from)?; + Ok(Json(result)) +} + pub async fn build_create_user_sql(Json(req): Json) -> Result, AppError> { Ok(Json(dbx_core::db_admin_sql::build_create_user_sql(&req.username, &req.password, &req.tablespace))) } diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 12cc1abb40..4cfb8818f1 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -102,6 +102,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 "host.events", "host.filesystem", "host.binary", + "host.plans:read", "host.network:https://s3.example.com:443" ], "entrypoints": { @@ -117,6 +118,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 - `engines.dbx` 是可选的 DBX 版本范围;`engines.host_api` 是必需的 Host API 版本声明。 - `host.workbench` 允许打开另一个工作台,`host.events` 接收宿主事件,`host.binary` 使用二进制通道,`host.filesystem` 使用文件系统入口。 +- `host.plans:read` 允许针对 DBX 自己的连接读取估算执行计划(见 [估算执行计划](#估算执行计划))。它是只读权限:不授予普通 SQL 执行、写操作、DDL 或真实执行计划,且这是唯一存在的计划类权限。 - `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。 - `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。 - `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。 @@ -291,12 +293,59 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `readAsset(path)` / `readAssetUrl(path)` | 读取包内资源;路径不能越出插件资源根目录。 | | `openWorkbench(id, context)` | 打开本插件的另一个工作台;需要 `host.workbench`。 | | `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 }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 | | `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 | 调用失败会以 Promise rejection 返回,处理时应展示可理解的错误并允许重试。通过 `request` 调用的宿主方法、参数和返回值以当前 Host API 版本为准,不要调用未在协议中声明的 DBX 内部函数。 `readAsset` 的路径相对于 `ui.root`,所以这里的 `assets/empty-state.svg` 对应包内 `ui/assets/empty-state.svg`。资源不用时调用 `URL.revokeObjectURL(assetUrl)`;组件销毁时调用 `onContext`/`onEvent` 等返回的取消订阅函数,避免重复监听。 +### 估算执行计划 + +插件可以在不自带驱动、连接池和凭据的前提下读取一条查询的**估算**执行计划。插件只传自己的原始 SQL 和连接引用;DBX 用自己已有的方言规则生成 `EXPLAIN` 语句、应用与自身计划视图完全相同的只读安全校验,并在宿主连接上执行。插件只拿到原始计划,由自己完成解析、归一化和可视化。 + +```json +{ + "permissions": ["host.plans:read"] +} +``` + +```js +if (window.dbxPlugin.capabilities.planApi) { + const capabilities = await window.dbxPlugin.getPlanCapabilities(connectionId); + if (capabilities.supports.estimatedPlan) { + const plan = await window.dbxPlugin.explainPlan({ connectionId, database, sql, mode: "estimated", timeoutMs: 15000 }); + render(plan.rawPlan, plan.format); + } +} +``` + +`getPlanCapabilities(connectionId)` 返回 `{ dbType, dbVersion?, supports: { estimatedPlan }, limits: { maxTimeoutMs, maxPlanBytes } }`: + +- `supports.estimatedPlan` 为 `false` 表示该连接的方言在 DBX 中没有估算计划路径;应直接禁用功能,而不是调用 `explainPlan`。 +- `limits.maxTimeoutMs` 已经考虑该连接自身的查询超时,请求里的 `timeoutMs` 会被夹到该上限;`limits.maxPlanBytes` 限制计划负载大小。 +- `dbVersion` 只在 DBX 已经知道该连接产品版本时才存在;宿主不会为了插件去探测服务器。 +- 该调用只读取已保存的连接配置,不会建立连接;但连接必须已经打开:已保存但当前已断开的连接会被拒绝,而不是替插件重新建连。 + +`explainPlan(request)` 返回 `{ dbType, dbVersion?, format, rawPlan, truncated, warnings }`: + +- `format` 为 `json`、`xml`(SQL Server `ShowPlanXML`)或 `text`。`format` 为 `json` 时 `rawPlan` 是已解析的 JSON 文档,其余情况是计划文本。 +- `warnings` 可能包含:`plan_not_json`(服务端返回的内容不是 JSON,负载会如实标记为 `text`,而不是假装可解析)、`plan_truncated`(宿主为遵守 `maxPlanBytes` 截断了计划)、`plan_rows_truncated`(驱动达到计划行数上限)。只要发生过任一截断,`truncated` 都是 `true`。 + +计划 API 的边界: + +- **只做估算计划。** `mode` 必须是 `"estimated"`,其它取值一律拒绝。真实执行计划(`EXPLAIN ANALYZE`、`SET STATISTICS XML`)会真正执行语句,不属于本 API。 +- **插件不能自己提供要执行的 SQL。** 只接受原始 `sql`,`EXPLAIN` 语句完全由宿主生成,因此插件无法传入 `EXPLAIN` 文本、驱动命令或执行模式。 +- **只允许只读目标。** DBX 自身计划视图使用的同一道校验会拒绝多语句、DDL、DML 和危险关键字。Oracle 是唯一例外:DBX 也会为它规划 DML,因为 `EXPLAIN PLAN FOR` 并不会执行该语句。 +- **不涉及凭据,也不返回结果集。** 返回体只有计划和元信息,密码、凭据、连接串、驱动内部结构都不会越过这条边界。 +- **连接必须已经打开。** DBX 不会替插件建立连接,也不会为插件创建连接。`getPlanCapabilities` 与 `explainPlan` 对「已保存但已断开」的连接都会以 `Connection is not open` 拒绝;只有 DBX 当前已经打开的连接才能规划。 + +判断能力时请读 `dbxPlugin.capabilities.planApi`(旧宿主不会带这个字段),再用 `getPlanCapabilities` 确认这条连接是否支持,最后才调用 `explainPlan`。自行拼 `EXPLAIN` 文本既没必要,也不会被接受。 + +计划 API 属于 Host API 1.2。无法离开它工作的插件应在 manifest 里声明下限:`"engines": { "host_api": "^1.2" }`。manifest 里的版本范围是兼容下限,`capabilities.planApi` 是运行期探测,两者都保留。 + ### 主题、布局和静态资源 DBX 将主题 tokens 注入文档根元素,并更新 `data-dbx-theme`。自定义组件库(包括 Svelte、shadcn-svelte)应使用这些变量,不要依赖父页面 CSS 自动穿透 iframe: diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index 3916719900..2160aa006e 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -102,6 +102,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con "host.events", "host.filesystem", "host.binary", + "host.plans:read", "host.network:https://s3.example.com:443" ], "entrypoints": { @@ -117,6 +118,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.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. - `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well. - `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target. @@ -291,12 +293,59 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `readAsset(path)` / `readAssetUrl(path)` | Reads a packaged asset within the plugin resource root. | | `openWorkbench(id, context)` | Opens another workbench from this plugin; requires `host.workbench`. | | `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 }` 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. | | `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. | Failed calls reject their Promise; show a useful error and offer retry. Host methods, parameters, and results are versioned API surface—do not call undocumented DBX internals. `readAsset` paths are relative to `ui.root`: `assets/empty-state.svg` resolves to the package's `ui/assets/empty-state.svg`. Call `URL.revokeObjectURL(assetUrl)` when done, and call the unsubscribe functions returned by `onContext`/`onEvent` when a component is destroyed. +### Estimated Execution Plans + +A plugin can read the **estimated** execution plan of a query without owning a driver, a connection pool, or a credential. It sends its own SQL and a connection reference; DBX builds the `EXPLAIN` statement with its own dialect rules, applies the same read-only safety gate it uses for its own plan view, and runs it on the host connection. The plugin receives only the raw plan, which it parses, normalizes, and visualizes itself. + +```json +{ + "permissions": ["host.plans:read"] +} +``` + +```js +if (window.dbxPlugin.capabilities.planApi) { + const capabilities = await window.dbxPlugin.getPlanCapabilities(connectionId); + if (capabilities.supports.estimatedPlan) { + const plan = await window.dbxPlugin.explainPlan({ connectionId, database, sql, mode: "estimated", timeoutMs: 15000 }); + render(plan.rawPlan, plan.format); + } +} +``` + +`getPlanCapabilities(connectionId)` returns `{ dbType, dbVersion?, supports: { estimatedPlan }, limits: { maxTimeoutMs, maxPlanBytes } }`: + +- `supports.estimatedPlan` is `false` when this connection's dialect has no estimated plan path in DBX; disable the feature instead of calling `explainPlan`. +- `limits.maxTimeoutMs` already accounts for the connection's own query timeout, and a requested `timeoutMs` is clamped to it. `limits.maxPlanBytes` caps the plan payload. +- `dbVersion` is present only when DBX already learned the product version for this connection; the host never probes the server on the plugin's behalf. +- The call only reads the stored connection config. It does not connect, but the connection must already be open: a saved connection that is currently disconnected is rejected instead of being opened for the plugin. + +`explainPlan(request)` returns `{ dbType, dbVersion?, format, rawPlan, truncated, warnings }`: + +- `format` is `json`, `xml` (SQL Server `ShowPlanXML`), or `text`. `rawPlan` is a parsed JSON document for `json` and the plan text otherwise. +- `warnings` carries `plan_not_json` when the server answered with something that is not JSON (the payload is then reported as `text` instead of pretending it is JSON), `plan_truncated` when the host cut the plan to respect `maxPlanBytes`, and `plan_rows_truncated` when the driver stopped collecting plan rows. `truncated` is `true` whenever the host cut the plan for either reason. + +The plan API is bounded by design: + +- **Estimated plans only.** `mode` must be `"estimated"`; any other value is rejected. Actual plans (`EXPLAIN ANALYZE`, `SET STATISTICS XML`) execute the statement and are not part of this API. +- **The plugin never supplies SQL to execute.** Only the source `sql` is accepted and the host builds the `EXPLAIN` statement itself, so a plugin cannot pass an `EXPLAIN` statement, a driver command, or an execution mode. +- **Read-only targets only.** The same gate DBX uses for its own plan view rejects multi-statement input, DDL, DML, and dangerous keywords. Oracle is the one dialect where DBX also plans DML, because `EXPLAIN PLAN FOR` does not execute it. +- **No credentials, no result set.** The response carries the plan and metadata only; a password, credential, connection string, or driver internals never cross this boundary. +- **The connection must already be open.** DBX does not connect on a plugin's behalf, and it does not create one for the plugin. Both `getPlanCapabilities` and `explainPlan` reject a saved-but-disconnected connection with `Connection is not open`; only a connection DBX already holds open can be planned. + +Gate on capability rather than probing: read `dbxPlugin.capabilities.planApi` from the init message (an older host omits it), then confirm per-connection support with `getPlanCapabilities` before calling `explainPlan`. Submitting `EXPLAIN` text of your own is neither necessary nor accepted. + +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. + ### Themes, Layout, and Static Assets DBX injects theme tokens on the document root and updates `data-dbx-theme`. Custom libraries, including Svelte and shadcn-svelte, should consume these variables; parent-page CSS does not cross the iframe: diff --git a/packages/app-tests/pluginPlanHostApi.test.ts b/packages/app-tests/pluginPlanHostApi.test.ts new file mode 100644 index 0000000000..4beb85c224 --- /dev/null +++ b/packages/app-tests/pluginPlanHostApi.test.ts @@ -0,0 +1,261 @@ +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_PLAN_BYTES, + MAX_PLUGIN_PLAN_SQL_CHARS, + MAX_PLUGIN_PLAN_TIMEOUT_MS, + PLUGIN_PLAN_PERMISSION, + PLUGIN_PLAN_WARNING, +} from "../../apps/desktop/src/types/pluginPlan.ts"; +import type { PluginPlanCapabilities, PluginPlanRequest } from "../../apps/desktop/src/types/pluginPlan.ts"; +import type { InstalledPlugin, PluginWorkbenchContribution } from "../../apps/desktop/src/types/database.ts"; + +/** + * The plan Host API (#9675) spans three languages: the Rust core that owns + * EXPLAIN generation, the TS bridge that enforces the manifest permission, and + * the plugin-facing SDK. Constant drift, a renamed field, or a permission that + * exists on only one side would fail silently at runtime, so these tests read + * the Rust sources and compare them against what the bridge really sends. + */ + +const planCoreSource = readFileSync("crates/dbx-core/src/query/plugin_plan.rs", "utf8"); +const manifestSource = readFileSync("crates/dbx-plugin-runtime/src/plugins/manifest.rs", "utf8"); +const manifestSchema = JSON.parse(readFileSync("plugins/manifest.schema.json", "utf8")); + +/** Extracts ``'s `pub` field names from a Rust source block. */ +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()); +} + +/** Reads a `const` or `pub const` literal from a Rust source block. */ +function rustConst(source: string, name: string): string { + const match = new RegExp(`(?:pub )?const ${name}\\s*:\\s*[^=]+=\\s*([^;]+);`).exec(source); + assert.notEqual(match, null, `Could not find const ${name}`); + return match![1].trim(); +} + +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, method: string, params: unknown, id = "1"): void { + bridge.handleWindowMessage({ + source: target, + data: { source: "dbx-plugin", version: 1, type: "request", id, method, params }, + } as MessageEvent); +} + +/** Polls until `check` passes; the bridge answers requests asynchronously. */ +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("plan limits and mode match the Rust core", () => { + // The plugin decides whether to run at all from these numbers, so a limit + // raised in Rust without the matching TS change must fail this test. + assert.equal(rustConst(planCoreSource, "MAX_PLUGIN_PLAN_TIMEOUT_MS").replaceAll("_", ""), String(MAX_PLUGIN_PLAN_TIMEOUT_MS)); + assert.equal(rustConst(planCoreSource, "MAX_PLUGIN_PLAN_SQL_CHARS").replaceAll("_", ""), String(MAX_PLUGIN_PLAN_SQL_CHARS)); + assert.equal( + rustConst(planCoreSource, "MAX_PLUGIN_PLAN_BYTES").replaceAll("_", "").replaceAll(" ", ""), + `4*1024*1024`, + ); + assert.equal(MAX_PLUGIN_PLAN_BYTES, 4 * 1024 * 1024); + assert.equal(rustConst(planCoreSource, "PLUGIN_PLAN_MODE_ESTIMATED"), '"estimated"'); +}); + +test("plan warning codes match the Rust core", () => { + assert.equal(rustConst(planCoreSource, "WARNING_PLAN_NOT_JSON"), `"${PLUGIN_PLAN_WARNING.notJson}"`); + assert.equal(rustConst(planCoreSource, "WARNING_PLAN_TRUNCATED"), `"${PLUGIN_PLAN_WARNING.truncated}"`); + assert.equal(rustConst(planCoreSource, "WARNING_PLAN_ROWS_TRUNCATED"), `"${PLUGIN_PLAN_WARNING.rowsTruncated}"`); +}); + +test("host.plans:read is declared by the runtime and the published schema", () => { + const supportedPermissions = /pub const SUPPORTED_PLUGIN_PERMISSIONS[^=]*=\s*&\[([^\]]*)\]/.exec(manifestSource); + assert.notEqual(supportedPermissions, null, "Could not find SUPPORTED_PLUGIN_PERMISSIONS"); + assert.ok(supportedPermissions![1].includes(`"${PLUGIN_PLAN_PERMISSION}"`), supportedPermissions![1]); + + const schemaPermissions: string[] = manifestSchema.properties.permissions.items.anyOf[0].enum; + assert.ok(schemaPermissions.includes(PLUGIN_PLAN_PERMISSION), schemaPermissions.join(", ")); + + // The read-only scope must stay the only plan permission: an execute scope + // would mean actual plans, which this API deliberately does not serve. + assert.equal(schemaPermissions.filter((permission) => permission.startsWith("host.plans")).length, 1); +}); + +test("the bridge sends exactly the fields the Rust request struct accepts", async () => { + const { target } = sandbox(); + const sent: PluginPlanRequest[] = []; + const bridge = new PluginHostBridge(plugin([PLUGIN_PLAN_PERMISSION]), workbench, {}, () => target, { + invoke: async () => undefined, + notify: async () => undefined, + sendBinary: async () => undefined, + readAsset: async () => ({ dataBase64: "", contentType: "" }), + getPlanCapabilities: async () => ({}) as PluginPlanCapabilities, + explainPlan: async (request) => { + sent.push(request); + return { dbType: "postgres", format: "json", rawPlan: {}, truncated: false, warnings: [] }; + }, + }); + + send(bridge, target, "host.explainPlan", { + connectionId: "c1", + database: "app", + schema: "public", + sql: "SELECT 1", + mode: "estimated", + timeoutMs: 1_000, + }); + await until(() => sent.length === 1, "the plan request"); + + const rustFields = rustStructFields(planCoreSource, "PluginPlanRequest").map(camelCase); + assert.deepEqual(Object.keys(sent[0]).sort(), [...rustFields].sort()); + // Field names must line up, not just the count: `connectionId` vs `connection_id` + // would deserialize into "missing field" on the Rust side. + assert.deepEqual(rustFields.sort(), ["connectionId", "database", "mode", "schema", "sql", "timeoutMs"]); +}); + +test("an undeclared plugin cannot reach the plan API", async () => { + const { messages, target } = sandbox(); + let called = 0; + const bridge = new PluginHostBridge(plugin([]), workbench, {}, () => target, { + invoke: async () => undefined, + notify: async () => undefined, + sendBinary: async () => undefined, + readAsset: async () => ({ dataBase64: "", contentType: "" }), + getPlanCapabilities: async () => { + called += 1; + return {} as PluginPlanCapabilities; + }, + explainPlan: async () => { + called += 1; + return {}; + }, + }); + + send(bridge, target, "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated" }); + await until(() => messages.length === 1, "the rejection"); + + assert.match(String(messages[0].error), /host\.plans:read/); + assert.equal(called, 0); +}); + +test("mode is required and only estimated is served", async () => { + const { messages, target } = sandbox(); + const sent: PluginPlanRequest[] = []; + const bridge = new PluginHostBridge(plugin([PLUGIN_PLAN_PERMISSION]), workbench, {}, () => target, { + invoke: async () => undefined, + notify: async () => undefined, + sendBinary: async () => undefined, + readAsset: async () => ({ dataBase64: "", contentType: "" }), + explainPlan: async (request) => { + sent.push(request); + return {}; + }, + }); + + for (const [id, mode] of [["actual", "actual"], ["analyze", "analyze"], ["missing", undefined]] as const) { + send(bridge, target, "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode }, id); + } + await until(() => messages.length === 3, "the three rejections"); + + assert.equal(sent.length, 0, "no non-estimated request may reach the backend"); + for (const message of messages) assert.match(String(message.error), /estimated/); +}); + +test("a host without the plan API advertises no capability and fails closed", async () => { + const { messages, target } = sandbox(); + const bridge = new PluginHostBridge(plugin([PLUGIN_PLAN_PERMISSION]), workbench, {}, () => target, { + invoke: async () => undefined, + notify: async () => undefined, + sendBinary: async () => undefined, + readAsset: async () => ({ dataBase64: "", contentType: "" }), + }); + + bridge.sendInit(); + assert.equal((messages[0].capabilities as { planApi: boolean }).planApi, false); + + send(bridge, target, "host.getPlanCapabilities", { connectionId: "c1" }); + await until(() => messages.length === 2, "the capability rejection"); + assert.match(String(messages[1].error), /unavailable/); +}); + +test("a plan request survives the bridge unchanged for the plugin", async () => { + const { messages, target } = sandbox(); + const capabilities: PluginPlanCapabilities = { + dbType: "postgres", + dbVersion: "15.19", + supports: { estimatedPlan: true }, + limits: { maxTimeoutMs: 30_000, maxPlanBytes: MAX_PLUGIN_PLAN_BYTES }, + }; + const result = { + dbType: "postgres", + dbVersion: "15.19", + format: "json" as const, + rawPlan: [{ Plan: { "Node Type": "Seq Scan", "Relation Name": "orders" } }], + truncated: false, + warnings: [], + }; + const bridge = new PluginHostBridge(plugin([PLUGIN_PLAN_PERMISSION]), workbench, {}, () => target, { + invoke: async () => undefined, + notify: async () => undefined, + sendBinary: async () => undefined, + readAsset: async () => ({ dataBase64: "", contentType: "" }), + getPlanCapabilities: async () => capabilities, + explainPlan: async () => result, + }); + + bridge.sendInit(); + assert.equal((messages[0].capabilities as { planApi: boolean }).planApi, true); + + send(bridge, target, "host.getPlanCapabilities", { connectionId: "c1" }, "caps"); + send(bridge, target, "host.explainPlan", { connectionId: "c1", sql: "SELECT 1", mode: "estimated" }, "plan"); + await until(() => messages.length === 3, "both plan responses"); + + assert.deepEqual(messages[1].result, capabilities); + assert.deepEqual(messages[2].result, result); + // Credentials never cross this boundary: the payload is the plan and metadata only. + const serialized = JSON.stringify(messages[2].result); + for (const secret of ["password", "credential", "connectionString", "username"]) { + assert.ok(!serialized.toLowerCase().includes(secret.toLowerCase()), secret); + } +}); diff --git a/plugins/README.md b/plugins/README.md index bdcffd29ff..aae7766cad 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -370,6 +370,8 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san - `readAsset(path)` / `readAssetUrl(path)` - `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) +- `capabilities` — `{ downloadFile, planApi }` 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 }` @@ -472,6 +474,86 @@ 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. +### 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. + +```json +{ + "permissions": ["host.plans:read"] +} +``` + +| Request | Purpose | +| --- | --- | +| `host.getPlanCapabilities({ connectionId })` | Reports what the host and this connection can plan. It only reads the stored connection config; it never connects or probes the server. The connection must already be open. | +| `host.explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`. The connection must already be open. | + +`host.getPlanCapabilities` returns: + +```json +{ + "dbType": "postgres", + "dbVersion": "15.19", + "supports": { "estimatedPlan": true }, + "limits": { "maxTimeoutMs": 60000, "maxPlanBytes": 4194304 } +} +``` + +- `supports.estimatedPlan` is `false` when this connection's dialect has no estimated plan path in DBX; disable the feature instead of calling `explainPlan`. +- `limits` are the host's ceilings. `maxTimeoutMs` already accounts for the connection's own query timeout, and a requested `timeoutMs` is clamped to it. A connection configured with no query timeout still gets the host ceiling. +- `dbVersion` is present only when DBX already learned the product version for this connection. The host never probes the server on the plugin's behalf. + +`host.explainPlan` returns: + +```json +{ + "dbType": "postgres", + "dbVersion": "15.19", + "format": "json", + "rawPlan": [{ "Plan": { "Node Type": "Seq Scan" } }], + "truncated": false, + "warnings": [] +} +``` + +- `format` is `json`, `xml` (SQL Server `ShowPlanXML`), or `text`. `rawPlan` is a parsed JSON document for `json` and the plan text otherwise, so the plugin can parse and normalize it itself. +- `warnings` carries `plan_not_json` when the server answered with something that is not JSON (the payload is then reported as `text` rather than pretending it is JSON), `plan_truncated` when the host cut the plan to respect `maxPlanBytes`, and `plan_rows_truncated` when the driver stopped collecting plan rows. +- `truncated` is `true` whenever the host cut the plan for either reason. + +The sandboxed UI can call this through `window.dbxPlugin` directly: + +```js +if (window.dbxPlugin.capabilities.planApi) { + const capabilities = await window.dbxPlugin.getPlanCapabilities(connectionId); + if (capabilities.supports.estimatedPlan) { + const plan = await window.dbxPlugin.explainPlan({ connectionId, database, sql, mode: "estimated", timeoutMs: 15000 }); + } +} +``` + +Boundaries: + +- **Estimated plans only.** `mode` must be `"estimated"`; any other value is rejected. Actual plans (`EXPLAIN ANALYZE`, `SET STATISTICS XML`) execute the statement and are not part of this API. +- **The plugin never supplies SQL to execute.** Only the source `sql` is accepted and the host builds the `EXPLAIN` statement itself, so a plugin cannot pass an `EXPLAIN` statement, a driver command, or an execution mode. +- **Read-only targets only.** The same gate DBX uses for its own plan view rejects multi-statement input, DDL, DML, and dangerous keywords. Oracle is the one dialect where DBX also plans DML, because `EXPLAIN PLAN FOR` does not execute it. +- **No credentials.** The response carries the plan and metadata only; a password, credential, connection string, or driver internals never cross this boundary, and the plan is not a user result set. +- **The connection must already be open.** DBX does not connect on a plugin's behalf. A saved connection that is currently disconnected is rejected by both `host.getPlanCapabilities` and `host.explainPlan` with `Connection is not open`; only a connection DBX already holds open can be planned. +- **`host.plans:read` is read-only.** It does not permit normal SQL execution, writes, DDL, or actual plans, and it is the only permission this API reads. +- **Bounded.** `limits.maxPlanBytes` caps the plan payload, `timeoutMs` is clamped by the host, and a plan that cannot be cut safely fails with an explicit error instead of returning a partial document. + +Gate on capability rather than probing: read `window.dbxPlugin.capabilities.planApi` from the init message (an older host omits it), then confirm per-connection support with `host.getPlanCapabilities` before calling `host.explainPlan`. + +The plan API is Host API 1.2, so a plugin that cannot work without it declares the floor in its manifest: + +```json +{ + "engines": { "host_api": "^1.2" } +} +``` + +The manifest range is a compatibility floor and `capabilities.planApi` is the runtime check; keep both. + ### Host API methods a plugin may call Plugins normally answer requests, but Host API 1.1 adds one method a plugin backend may call back into DBX. Plugin-initiated requests use **string** ids (`"prompt-1"`), while DBX-owned requests and their responses keep numeric ids, so one stream carries both directions and older hosts that only understand numeric ids ignore the new frames instead of failing. @@ -584,7 +666,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. +- **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. 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 c07063075a..d963513dae 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"] }, + { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read"] }, { "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" } ] } diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index edf167e1f0..591eff87c0 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -948,10 +948,29 @@ pub async fn get_explain_info( schema.as_deref(), &sql, mode.as_deref(), + None, ) .await } +#[tauri::command] +pub async fn get_plugin_plan_capabilities( + state: tauri::State<'_, std::sync::Arc>, + connection_id: String, +) -> Result { + dbx_core::query::plugin_plan::plugin_plan_capabilities(&state, &connection_id).await +} + +/// Read-only estimated plan acquisition for the plugin Host API. The request +/// carries the original SQL only; the host generates and owns the EXPLAIN. +#[tauri::command] +pub async fn get_plugin_estimated_plan( + state: tauri::State<'_, std::sync::Arc>, + request: dbx_core::query::plugin_plan::PluginPlanRequest, +) -> Result { + dbx_core::query::plugin_plan::explain_estimated_plan(&state, request).await +} + #[tauri::command] pub fn build_create_user_sql(username: String, password: String, tablespace: String) -> Result { Ok(dbx_core::db_admin_sql::build_create_user_sql(&username, &password, &tablespace)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3f39657711..484098377a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1996,6 +1996,8 @@ pub fn run() { commands::query::build_sorted_query_sql, commands::query::build_explain_sql, commands::query::get_explain_info, + commands::query::get_plugin_plan_capabilities, + commands::query::get_plugin_estimated_plan, commands::query::build_create_user_sql, commands::query::build_dropped_file_preview_sql, commands::query::build_table_select_sql,