fix(plugins): preserve workbench snapshots and connection config

Refs: #8403
This commit is contained in:
t8y2
2026-09-13 01:10:07 +08:00
parent 0ecbfbfeb0
commit 0d5d15b20b
11 changed files with 472 additions and 21 deletions
+14
View File
@@ -976,6 +976,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
openDbFilePath,
openConnectionDeepLink,
openAiConfigDeepLink,
closeActiveSurface,
});
const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose });
useVisibilityChange();
@@ -2407,6 +2408,19 @@ async function openConnectionQuery(connectionId: string) {
}
return;
}
if (initialTarget.kind === "plugin-workbench") {
try {
await queryStore.openPluginConnection(connectionId);
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e),
}),
5000,
);
}
return;
}
if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper" || initialTarget.kind === "consul") {
try {
await connectionStore.ensureConnected(connectionId);
@@ -4020,7 +4020,7 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
setGaussdbIdentifierQuoteStyle(config, style);
setGaussdbTargetServerType(config, targetServerType);
setGaussdbCountQueryDop(config, countQueryDop);
} else if (!isDoltDriverProfile(config.driver_profile)) {
} else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile)) {
config.external_config = undefined;
}
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
@@ -22,6 +22,12 @@ describe("ConnectionDialog driver profile configuration", () => {
const submitConfig = connectionConfigForSubmitSource();
expect(submitConfig).toContain("setGaussdbTargetServerType(config, targetServerType)");
expect(submitConfig).toContain("else if (!isDoltDriverProfile(config.driver_profile))");
expect(submitConfig).toContain('else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile))');
});
it("preserves plugin external configuration on submit", () => {
const submitConfig = connectionConfigForSubmitSource();
expect(submitConfig).toContain('else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile))');
});
});
@@ -15,6 +15,7 @@ import type {
PluginWorkbenchContribution,
} from "@/types/database";
import { uuid } from "@/lib/common/utils";
import { clonePluginData } from "./pluginData";
const PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX = "plugin-provider:";
@@ -146,7 +147,7 @@ export function pluginConnectionFormValues(contribution: PluginConnectionProvide
}
export function buildPluginConnectionConfig(pluginId: string, contribution: PluginConnectionProviderContribution, values: Record<string, PluginFormFieldValue>, existing?: ConnectionConfig): ConnectionConfig {
const externalConfig: Record<string, unknown> = isRecord(existing?.external_config) ? structuredClone(existing.external_config) : {};
const externalConfig: Record<string, unknown> = isRecord(existing?.external_config) ? clonePluginData(existing.external_config) : {};
const connectionSecrets = { ...existing?.connection_secrets };
const config: ConnectionConfig = {
id: existing?.id || uuid(),
+103
View File
@@ -0,0 +1,103 @@
import { isProxy, toRaw } from "vue";
export const MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES = 2 * 1024 * 1024;
export function clonePluginData<T>(value: T): T {
const unwrapped = unwrapVueProxies(value);
try {
if (typeof structuredClone === "function") return structuredClone(unwrapped);
return JSON.parse(JSON.stringify(unwrapped)) as T;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Plugin data cannot be cloned: ${message}`);
}
}
export function snapshotPluginWorkbenchContext<T extends object>(value: T): T {
const snapshot = snapshotValue(value, "context", new WeakSet<object>());
if (!snapshot || Array.isArray(snapshot) || typeof snapshot !== "object") {
throw new Error("Plugin workbench context must be an object");
}
const size = new TextEncoder().encode(JSON.stringify(snapshot)).byteLength;
if (size > MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES) {
throw new Error(`Plugin workbench context exceeds ${MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES} bytes`);
}
return snapshot as T;
}
type PluginContextValue = boolean | number | string | null | PluginContextValue[] | { [key: string]: PluginContextValue } | undefined;
function snapshotValue(value: unknown, path: string, ancestors: WeakSet<object>): PluginContextValue {
if (value === undefined) return undefined;
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (Number.isFinite(value)) return value;
throw new Error(`Plugin workbench context contains a non-finite number at ${path}`);
}
if (typeof value !== "object") throw new Error(`Plugin workbench context contains an unsupported value at ${path}`);
const raw = isProxy(value) ? toRaw(value) : value;
if (ancestors.has(raw)) throw new Error(`Plugin workbench context contains a circular reference at ${path}`);
const prototype = Object.getPrototypeOf(raw);
if (!Array.isArray(raw) && prototype !== Object.prototype && prototype !== null) {
throw new Error(`Plugin workbench context requires plain data at ${path}`);
}
ancestors.add(raw);
try {
if (Array.isArray(raw)) {
return raw.map((item, index) => snapshotValue(item, `${path}[${index}]`, ancestors) ?? null);
}
const result: { [key: string]: PluginContextValue } = {};
for (const [key, item] of Object.entries(raw)) {
const snapshot = snapshotValue(item, `${path}.${key}`, ancestors);
if (snapshot !== undefined) result[key] = snapshot;
}
return result;
} finally {
ancestors.delete(raw);
}
}
function unwrapVueProxies<T>(value: T, seen = new WeakMap<object, unknown>()): T {
const rawValue = isProxy(value) ? toRaw(value) : value;
if (!rawValue || typeof rawValue !== "object") return rawValue;
const object = rawValue as object;
const existing = seen.get(object);
if (existing) return existing as T;
if (rawValue instanceof Date || rawValue instanceof RegExp || rawValue instanceof ArrayBuffer || ArrayBuffer.isView(rawValue)) {
return rawValue;
}
if (Array.isArray(rawValue)) {
const result: unknown[] = [];
seen.set(object, result);
rawValue.forEach((item, index) => {
result[index] = unwrapVueProxies(item, seen);
});
return result as T;
}
if (rawValue instanceof Map) {
const result = new Map<unknown, unknown>();
seen.set(object, result);
rawValue.forEach((item, key) => result.set(unwrapVueProxies(key, seen), unwrapVueProxies(item, seen)));
return result as T;
}
if (rawValue instanceof Set) {
const result = new Set<unknown>();
seen.set(object, result);
rawValue.forEach((item) => result.add(unwrapVueProxies(item, seen)));
return result as T;
}
const prototype = Object.getPrototypeOf(rawValue);
if (prototype !== Object.prototype && prototype !== null) return rawValue;
const result = Object.create(prototype) as Record<PropertyKey, unknown>;
seen.set(object, result);
for (const key of Reflect.ownKeys(rawValue)) {
const descriptor = Object.getOwnPropertyDescriptor(rawValue, key);
if (!descriptor || !("value" in descriptor)) continue;
Object.defineProperty(result, key, { ...descriptor, value: unwrapVueProxies(descriptor.value, seen) });
}
return result as T;
}
+60
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { reactive, readonly } from "vue";
import { PluginHostBridge, pluginSandboxDocument } from "./pluginHostBridge";
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
@@ -57,6 +58,65 @@ describe("PluginHostBridge", () => {
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "init", locale: "zh-CN", context: { connectionId: "connection" } });
});
it("snapshots nested Vue reactive context values before sending them to the plugin", () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const context = {
connectionId: "connection",
values: reactive({ path: "/tmp", options: readonly({ recursive: true }) }),
};
const bridge = new PluginHostBridge(plugin(), workbench, context, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
bridge.sendInit();
expect(messages[0]).toMatchObject({
type: "init",
context: { connectionId: "connection", values: { path: "/tmp", options: { recursive: true } } },
});
expect((messages[0] as { context: typeof context }).context).not.toBe(context);
});
it("reports unsupported plugin context values", () => {
expect(
() =>
new PluginHostBridge(plugin(), workbench, { value: () => undefined }, () => null, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
}),
).toThrow("Plugin workbench context contains an unsupported value");
});
it("rejects circular and oversized plugin contexts", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(
() =>
new PluginHostBridge(plugin(), workbench, circular, () => null, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
}),
).toThrow("circular reference");
expect(
() =>
new PluginHostBridge(plugin(), workbench, { value: "x".repeat(2 * 1024 * 1024) }, () => null, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
}),
).toThrow("exceeds");
});
it("rejects privileged host calls without manifest permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -1,4 +1,5 @@
import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginWorkbenchContribution } from "@/types/database";
import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData";
const PLUGIN_MESSAGE_SOURCE = "dbx-plugin";
const HOST_MESSAGE_SOURCE = "dbx-host";
@@ -54,9 +55,9 @@ export class PluginHostBridge {
locale = "en",
theme?: PluginBridgeTheme,
) {
this.context = structuredCloneSafe(context);
this.context = snapshotPluginWorkbenchContext(context);
this.locale = locale;
this.theme = theme ? structuredCloneSafe(theme) : undefined;
this.theme = theme ? clonePluginData(theme) : undefined;
}
handleWindowMessage(event: MessageEvent): boolean {
@@ -80,9 +81,9 @@ export class PluginHostBridge {
pluginId: this.plugin.manifest.id,
contributionId: this.workbench.id,
locale: this.locale,
theme: this.theme ? structuredCloneSafe(this.theme) : undefined,
theme: this.theme ? clonePluginData(this.theme) : undefined,
permissions: [...(this.plugin.manifest.permissions || [])],
context: structuredCloneSafe(this.context),
context: snapshotPluginWorkbenchContext(this.context),
});
}
@@ -92,8 +93,8 @@ export class PluginHostBridge {
* a full reload; context-only changes must not lose plugin state.
*/
updateContext(context: PluginWorkbenchContext): void {
this.context = structuredCloneSafe(context);
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "context", context: structuredCloneSafe(this.context) });
this.context = snapshotPluginWorkbenchContext(context);
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "context", context: snapshotPluginWorkbenchContext(this.context) });
}
/** Notify the plugin UI about a locale change without a reload. */
@@ -104,7 +105,7 @@ export class PluginHostBridge {
/** Push resolved theme tokens so the plugin UI can follow DBX light/dark and palette changes. */
updateTheme(theme: PluginBridgeTheme): void {
this.theme = structuredCloneSafe(theme);
this.theme = clonePluginData(theme);
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "env", locale: this.locale, theme: this.theme });
}
@@ -132,7 +133,7 @@ export class PluginHostBridge {
}
private async dispatch(method: string, params: unknown, binary?: ArrayBuffer): Promise<unknown> {
if (method === "host.getContext") return structuredCloneSafe(this.context);
if (method === "host.getContext") return snapshotPluginWorkbenchContext(this.context);
if (method === "backend.invoke") {
const input = requireRecord(params, "backend.invoke params");
const backendMethod = requireProtocolName(input.method, "backend method");
@@ -451,11 +452,6 @@ function enforcePayloadLimit(value: unknown): void {
if (bytes > MAX_BRIDGE_PAYLOAD_BYTES) throw new Error("Plugin bridge request is too large");
}
function structuredCloneSafe<T>(value: T): T {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+105 -3
View File
@@ -4,9 +4,8 @@ import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonit
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
@@ -75,6 +74,8 @@ import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { batchSqlRecoverySql, batchSqlRecoveryState, mergeBatchQueryResults, offsetBatchQueryResultIndexes, prepareBatchSqlRecovery, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive";
import * as api from "@/lib/backend/api";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { snapshotPluginWorkbenchContext } from "@/lib/plugins/pluginData";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -92,7 +93,7 @@ import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeS
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import { disposeAllSqlServerActivityTraces, disposeSqlServerActivityTrace } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
import type { SavedSqlFile } from "@/types/database";
import i18n from "@/i18n";
import i18n, { currentLocale } from "@/i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
import type { DriverProfileWorkspaceScope } from "@/lib/database/driverProfileExtensions";
@@ -3146,6 +3147,104 @@ export const useQueryStore = defineStore("query", () => {
tab.nacosTargetKeyword = undefined;
}
function openPluginWorkbench(pluginId: string, contributionId: string, options: { title?: string; connectionId?: string; database?: string; context?: Record<string, unknown>; forceNew?: boolean } = {}) {
if (!options.forceNew) {
const existing = tabs.value.find((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench?.pluginId === pluginId && tab.pluginWorkbench?.contributionId === contributionId && tab.connectionId === (options.connectionId || ""));
if (existing) {
if (options.context) existing.pluginWorkbench = { ...existing.pluginWorkbench!, context: snapshotPluginWorkbenchContext(options.context) };
switchTab(existing.id);
return existing.id;
}
}
const id = uuid();
const tab: QueryTab = {
id,
title: options.title || contributionId,
connectionId: options.connectionId || "",
database: options.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "plugin-workbench",
pluginWorkbench: {
pluginId,
contributionId,
context: options.context ? snapshotPluginWorkbenchContext(options.context) : undefined,
},
};
tabs.value.push(tab);
activeTabId.value = id;
return id;
}
function openPluginFilesystem(pluginId: string, providerId: string, options: { title?: string; connectionId?: string; rootUri?: string; currentUri?: string; forceNew?: boolean } = {}) {
if (!options.forceNew) {
const existing = tabs.value.find((tab) => tab.mode === "plugin-filesystem" && tab.pluginFilesystem?.pluginId === pluginId && tab.pluginFilesystem?.providerId === providerId && tab.connectionId === (options.connectionId || ""));
if (existing) {
if (options.currentUri) existing.pluginFilesystem = { ...existing.pluginFilesystem!, currentUri: options.currentUri };
switchTab(existing.id);
return existing.id;
}
}
const id = uuid();
const tab: QueryTab = {
id,
title: options.title || providerId,
connectionId: options.connectionId || "",
database: "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "plugin-filesystem",
pluginFilesystem: {
pluginId,
providerId,
rootUri: options.rootUri,
currentUri: options.currentUri,
},
};
tabs.value.push(tab);
activeTabId.value = id;
return id;
}
async function openPluginConnection(connectionId: string) {
const connectionStore = useConnectionStore();
const connection = connectionStore.getConfig(connectionId);
if (!connection || connection.db_type !== "plugin") throw new Error("Plugin connection config not found");
const pluginId = connection.plugin_id;
const providerId = connection.plugin_connection_provider;
if (!pluginId || !providerId) throw new Error("Plugin connection binding is incomplete");
const registry = createFrontendPluginRegistry(await api.listPlugins(), currentLocale());
const provider = registry.listConnectionProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
if (!provider) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' is unavailable`);
const workbench = provider.contribution.workbench ? registry.findWorkbench(pluginId, provider.contribution.workbench) : undefined;
await connectionStore.ensureConnected(connectionId);
if (workbench) {
return openPluginWorkbench(pluginId, workbench.contribution.id, {
title: `${connection.name} · ${workbench.contribution.label}`,
connectionId,
context: {
connectionId,
providerId,
connectionType: connection.plugin_connection_type,
},
});
}
const filesystemProviderId = provider.contribution.filesystem_provider;
const filesystem = filesystemProviderId ? registry.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === filesystemProviderId) : undefined;
if (!filesystem) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' does not declare a workbench or filesystem provider`);
return openPluginFilesystem(pluginId, filesystem.contribution.id, {
title: `${connection.name} · ${filesystem.contribution.label}`,
connectionId,
rootUri: filesystem.contribution.root_uri,
});
}
function applyTableStructureInitialTab(tab: QueryTab, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget) {
if (!initialTab && !initialTarget?.name) return;
if (initialTab) tab.structureInitialTab = initialTab;
@@ -8089,6 +8188,9 @@ export const useQueryStore = defineStore("query", () => {
openMqttAdmin,
openNacosAdmin,
clearNacosNavigationTarget,
openPluginWorkbench,
openPluginFilesystem,
openPluginConnection,
openTableStructure,
linkSavedSql,
linkExternalSqlPath,
+155 -2
View File
@@ -131,6 +131,10 @@ export interface ConnectionConfig {
gbase_server?: string;
informix_server?: string;
external_config?: unknown;
plugin_id?: string;
plugin_connection_provider?: string;
plugin_connection_type?: string;
connection_secrets?: Record<string, string>;
one_time?: boolean;
/**
* Whether the database password may be persisted locally. When false, the
@@ -431,19 +435,155 @@ export interface PluginManifestLocalization {
contributions?: Record<string, PluginContributionLocalization>;
}
export interface PluginCompatibility {
compatible: boolean;
errors?: string[];
warnings?: string[];
target?: string;
}
export interface PluginManifest {
manifest_version?: number;
id: string;
name: string;
icon?: string;
version?: string;
publisher?: string;
engines?: PluginEngines;
permissions?: string[];
entrypoints?: PluginEntrypoints;
protocol_version?: number;
description?: string;
executable?: string;
drivers: PluginDriverManifest[];
contributions?: PluginContribution[];
localizations?: Record<string, PluginManifestLocalization>;
}
export interface InstalledPlugin {
manifest: PluginManifest;
path: string;
compatibility: PluginCompatibility;
path?: string;
}
export interface PluginTrustedKey {
keyId: string;
publicKey: string;
}
export type PluginRepositoryKind = "official" | "custom" | "enterprise";
export interface PluginRepository {
id: string;
name: string;
kind: PluginRepositoryKind;
catalogUrl?: string;
enabled: boolean;
managed: boolean;
}
export interface PluginMarketplaceRepositoryMetadata {
id: string;
name: string;
homepage?: string;
}
export interface PluginMarketplaceLocalization {
name?: string;
description?: string;
}
export interface PluginMarketplaceArtifact {
target: string;
url: string;
sha256: string;
signingKeyId: string;
size?: number;
}
export interface PluginMarketplaceVersion {
version: string;
releasedAt?: string;
releaseNotes?: string;
artifacts: PluginMarketplaceArtifact[];
}
export interface PluginMarketplacePlugin {
id: string;
name: string;
description: string;
publisher: string;
verified: boolean;
icon?: string;
tags: string[];
permissions: string[];
source?: string;
homepage?: string;
license?: string;
latestVersion: string;
versions: PluginMarketplaceVersion[];
localizations?: Record<string, PluginMarketplaceLocalization>;
}
export interface PluginMarketplaceCatalog {
catalogVersion: number;
repository: PluginMarketplaceRepositoryMetadata;
generatedAt?: string;
plugins: PluginMarketplacePlugin[];
}
export interface PluginRepositoryCatalogResult {
repository: PluginRepository;
target: string;
catalog?: PluginMarketplaceCatalog;
error?: string;
}
export interface PluginMarketplaceInstallRequest {
repositoryId: string;
pluginId: string;
version?: string;
}
export interface ActivePluginSession {
pluginId: string;
processId?: number;
state: "starting" | "running" | "stopping" | "stopped" | "exited";
}
export interface PluginUiAssetPayload {
contentType: string;
dataBase64: string;
etag: string;
}
export interface PluginConnectionActionResult {
message?: string;
fieldValues?: Record<string, PluginFormFieldValue | null>;
}
export interface PluginInstallResult {
plugin: InstalledPlugin;
previousVersion?: string;
packageSha256: string;
signature: { status: "trusted"; key_id: string } | { status: "unsigned" };
}
export interface PluginRollbackResult {
plugin: InstalledPlugin;
previousVersion: string;
}
export interface PluginEvent {
pluginId: string;
method: string;
params: unknown;
}
export interface PluginBinaryEvent {
pluginId: string;
channel: string;
dataBase64: string;
}
export interface JdbcDriverInfo {
@@ -1465,7 +1605,20 @@ export interface QueryTab {
| "mysql-dashboard"
| "postgres-dashboard"
| "xugu-dashboard"
| "dolt-version-control";
| "dolt-version-control"
| "plugin-workbench"
| "plugin-filesystem";
pluginWorkbench?: {
pluginId: string;
contributionId: string;
context?: Record<string, unknown>;
};
pluginFilesystem?: {
pluginId: string;
providerId: string;
rootUri?: string;
currentUri?: string;
};
/** Ephemeral navigation intent; it is consumed by HBaseBrowser and is not persisted. */
hbaseCreateTableOnOpen?: boolean;
mqTenant?: string;
@@ -43,6 +43,14 @@ my-plugin/
- `.dbxp` 是安装包,不是源码仓库。
- 前端插件可以发布一个 `universal` 包;包含原生 Sidecar 的插件需要按操作系统和 CPU 架构分别构建。
## Workbench 上下文契约
Workbench 上下文跨越 DBX 与插件边界时是 JSON 数据快照。宿主会递归移除 Vue 响应式包装并发送独立副本,因此插件不能依赖 Vue ref、Proxy、DOM 节点、函数、组件实例或凭据。
上下文可以包含 `null`、布尔值、有限数字、字符串、数组和普通对象。对象中的 `undefined` 字段会省略,数组中的 `undefined` 会转换为 `null`。Date、Map、Set、Symbol、BigInt、非有限数字、循环引用和自定义 class 实例会被拒绝并返回错误。上下文 UTF-8 编码后的大小上限为 2 MiB。
连接 Provider 使用 `binding: "config"` 的字段会保存到 `external_config`;使用 `binding: "secret"` 的字段会保存到 Secret Store。插件连接的 `external_config` 会在新建、编辑、保存和重新连接流程中保留。
## 创建插件
直接安装预编译 CLI,不需要 Rust,也不需要克隆 DBX 源码:
+8
View File
@@ -43,6 +43,14 @@ my-plugin/
- `.dbxp` is an installable artifact, not the source repository.
- Frontend-only plugins can publish one `universal` package. Native sidecars require packages for each operating-system and CPU target.
## Workbench Context Contract
Workbench context is a JSON data snapshot across the DBX/plugin boundary. The host recursively removes Vue reactivity and sends an independent copy, so plugins must not expect Vue refs, proxies, DOM nodes, functions, component instances, or credentials.
Context may contain `null`, booleans, finite numbers, strings, arrays, and plain objects. Undefined object properties are omitted and undefined array entries become `null`. Dates, Maps, Sets, symbols, bigints, non-finite numbers, circular references, and custom class instances are rejected with an error. The UTF-8 encoded context is limited to 2 MiB.
Connection provider fields using `binding: "config"` are persisted in `external_config`; fields using `binding: "secret"` are persisted in the Secret Store. Plugin connection configuration keeps `external_config` through create, edit, save, and reconnect flows.
## Create a Plugin
Install the precompiled CLI without Rust or a DBX source checkout: