feat(plugins): expose read-only estimated plan host api

This commit is contained in:
jearhe authored and GitHub committed 2026-09-20 17:15:52 +08:00
1 parent ea16af7f28
commit f909f85075
23 files changed
+2059 -17

No files matched your search

+222 -1
View File
@@ -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<unknown> };
dbxPlugin?: {
invoke: (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise<unknown>;
getPlanCapabilities: (connectionId: string) => Promise<unknown>;
explainPlan: (request: unknown) => Promise<unknown>;
};
}
function loadSdk(posted: unknown[], initialTheme?: { appearance: "dark" | "light"; tokens: Record<string, string> }): 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<string, unknown>;
}[];
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" });
});
});
@@ -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> | void;
/** Explicit user-triggered reconnect of an owned plugin connection (full flow, interactive password prompt allowed). */
reopenConnection?(pluginId: string, connectionId: string): Promise<void>;
/**
* 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<PluginPlanCapabilities>;
/**
* 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<PluginPlanResult>;
closeTab?(): Promise<void> | 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<PluginSaveFileResult | null>;
@@ -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<string, unknown>): 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);