From dda947db5af3924e7ba28a0ec56308f1f22e57da Mon Sep 17 00:00:00 2001 From: fuleinist Date: Tue, 4 Aug 2026 12:20:12 +1000 Subject: [PATCH 1/2] feat(connections): opaque plugin-specific extra fields for ConnectionParams Adds a generic mechanism for plugins to carry custom connection settings (e.g. an AWS region for DynamoDB) without core schema changes. - ConnectionParams gains `extra: HashMap` (Rust) and `extra?: Record` (TS). Opaque to the host: persisted verbatim, forwarded to driver plugins, absent from JSON when empty. - New plugin slot `connection-modal.extra_fields` rendered below the host/port section; context exposes `driver`, `extra`, and `setExtraField(key, value)` so plugin UI can edit the map. - Pure `updateExtraField` helper in src/utils/connections.ts with unit tests (8 cases: set, merge, immutability, clear-on-empty, blank-key handling). - Host API version 0.1.0 -> 0.2.0 (HOST_API_VERSION, plugin-api API_VERSION + package.json) since the slot map is observable to plugin bundles. - PLUGIN_GUIDE slot table documents the new slot. Follow-up to TabularisDB/tabularis-dynamodb-plugin#59. --- packages/plugin-api/package.json | 2 +- packages/plugin-api/src/slots.ts | 7 +++ packages/plugin-api/src/version.ts | 2 +- plugins/PLUGIN_GUIDE.md | 1 + src-tauri/src/models.rs | 8 +++ src-tauri/src/models_tests.rs | 57 ++++++++++++++++++++ src-tauri/src/plugins/driver.rs | 1 + src/components/modals/NewConnectionModal.tsx | 29 ++++++++++ src/contexts/PluginSlotProvider.tsx | 2 +- src/types/pluginSlots.ts | 4 +- src/utils/connections.ts | 31 +++++++++++ src/utils/credentials.ts | 3 ++ tests/utils/connections.test.ts | 49 +++++++++++++++++ 13 files changed, 192 insertions(+), 4 deletions(-) diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index 852e59172..a37fedc25 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@tabularis/plugin-api", - "version": "0.1.0", + "version": "0.2.0", "description": "Public API surface for Tabularis plugin UI extensions.", "license": "Apache-2.0", "homepage": "https://github.com/TabularisDB/tabularis/tree/main/packages/plugin-api", diff --git a/packages/plugin-api/src/slots.ts b/packages/plugin-api/src/slots.ts index baf11117e..6c0d7a055 100644 --- a/packages/plugin-api/src/slots.ts +++ b/packages/plugin-api/src/slots.ts @@ -74,6 +74,13 @@ export type SlotContextMap = { "connection-modal.connection_content": { driver: string; }; + "connection-modal.extra_fields": { + driver: string; + /** Current values of the plugin-owned extra connection fields. */ + extra: Record; + /** Update one extra field. Pass an empty string to clear it. */ + setExtraField: (key: string, value: string) => void; + }; }; /** diff --git a/packages/plugin-api/src/version.ts b/packages/plugin-api/src/version.ts index 8d7746cd8..2ab36f01d 100644 --- a/packages/plugin-api/src/version.ts +++ b/packages/plugin-api/src/version.ts @@ -2,7 +2,7 @@ * API version of this package. Must match the version field of package.json. * Bump when the host API shape changes in a way that plugin bundles can observe. */ -export const API_VERSION = "0.1.0"; +export const API_VERSION = "0.2.0"; /** * Minimum Tabularis host version that exposes an API compatible with this package. diff --git a/plugins/PLUGIN_GUIDE.md b/plugins/PLUGIN_GUIDE.md index 7b06802c9..8969bd951 100644 --- a/plugins/PLUGIN_GUIDE.md +++ b/plugins/PLUGIN_GUIDE.md @@ -339,6 +339,7 @@ Add an optional `ui_extensions` array to your `manifest.json`: | `settings.plugin.actions` | Per-plugin actions in Settings modal | `targetPluginId` | Diagnostics, re-auth buttons | | `settings.plugin.before_settings` | Content above plugin settings form | `targetPluginId` | OAuth panels, status banners | | `connection-modal.connection_content` | Inside the connection form | `driver` | Custom connection fields | +| `connection-modal.extra_fields` | Below host/port in the connection form | `driver`, `extra`, `setExtraField` | Plugin-specific connection fields (e.g. AWS region) | ### SlotContext diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index ff2247727..7ceaa7235 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -262,6 +262,14 @@ pub struct ConnectionParams { /// pool hands out. #[serde(default, skip_serializing_if = "Option::is_none")] pub startup_script: Option, + /// Opaque, plugin-specific connection fields. The host does not interpret + /// these — they are persisted verbatim and forwarded to the driver plugin + /// as part of `params`, so plugins can carry custom connection settings + /// (e.g. an AWS region for DynamoDB) without core schema changes. + /// Rendered by plugins through the `connection-modal.extra_fields` slot. + /// Absent from the JSON when empty. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub extra: HashMap, // Connection ID for stable pooling (not persisted, set at runtime) #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, diff --git a/src-tauri/src/models_tests.rs b/src-tauri/src/models_tests.rs index 4dbdac134..e6d4d1967 100644 --- a/src-tauri/src/models_tests.rs +++ b/src-tauri/src/models_tests.rs @@ -139,4 +139,61 @@ mod tests { ); assert_eq!(params.connection_uri_in_keychain, Some(true)); } + + /// `extra` is optional on the wire: connections persisted before the + /// field existed must deserialize with an empty map, not an error. + #[test] + fn connection_params_default_extra_when_absent() { + let stored = r#"{ + "driver": "mysql", + "host": "localhost", + "database": "app" + }"#; + + let params: ConnectionParams = + serde_json::from_str(stored).expect("legacy params without extra"); + assert!(params.extra.is_empty()); + } + + /// `extra` entries survive a persist round-trip untouched — the host must + /// not interpret or rewrite plugin-specific values. + #[test] + fn connection_params_round_trip_extra_fields() { + let params = ConnectionParams { + driver: "dynamodb".to_string(), + database: DatabaseSelection::Single(String::new()), + extra: [ + ("region".to_string(), "eu-west-1".to_string()), + ("endpoint".to_string(), "http://localhost:8000".to_string()), + ] + .into_iter() + .collect(), + ..Default::default() + }; + + let json = serde_json::to_string(¶ms).expect("serialize params"); + assert!(json.contains("\"extra\"")); + + let restored: ConnectionParams = + serde_json::from_str(&json).expect("deserialize params"); + assert_eq!(restored.extra.get("region").map(String::as_str), Some("eu-west-1")); + assert_eq!( + restored.extra.get("endpoint").map(String::as_str), + Some("http://localhost:8000") + ); + } + + /// An empty `extra` map is omitted from the persisted JSON so legacy + /// connections.json files stay byte-identical. + #[test] + fn connection_params_omit_empty_extra() { + let params = ConnectionParams { + driver: "mysql".to_string(), + database: DatabaseSelection::Single("app".to_string()), + ..Default::default() + }; + + let json = serde_json::to_string(¶ms).expect("serialize params"); + assert!(!json.contains("extra")); + } } diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 0e01969d3..542a55e62 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -1355,6 +1355,7 @@ mod tests { k8s_kubeconfig_path: None, startup_script: None, use_iam_auth: None, + extra: HashMap::new(), connection_id: Some("conn-1".to_string()), } } diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index ecac6d3a0..71fac7851 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -53,6 +53,7 @@ import { useK8sPathOverrides } from "../../hooks/useK8sPathOverrides"; import { useLatestAsync } from "../../hooks/useLatestAsync"; import { K8sAdvancedSettings } from "../ui/K8sAdvancedSettings"; import { isMultiDatabaseCapable } from "../../utils/database"; +import { updateExtraField } from "../../utils/connections"; import { toErrorMessage } from "../../utils/errors"; import { classifyConnectionError, @@ -141,6 +142,9 @@ interface ConnectionParams { k8s_kubeconfig_path?: string; // SQL run on every new connection (e.g. SET / set_config) startup_script?: string; + // Opaque plugin-specific connection fields, forwarded verbatim to the + // driver/plugin and persisted as-is in connections.json. + extra?: Record; } interface SavedConnection { @@ -616,6 +620,24 @@ export const NewConnectionModal = ({ dbFieldSlotContext, ).length > 0; + // ── plugin slot: connection-modal.extra_fields ── + // Plugins render their own connection fields (e.g. an AWS region select) + // and write them into the opaque `extra` map via `setExtraField`. + const setExtraField = useCallback((key: string, value: string) => { + setFormData((prev) => ({ + ...prev, + extra: updateExtraField(prev.extra, key, value), + })); + }, []); + const extraFieldsSlotContext = useMemo( + () => ({ + driver, + extra: formData.extra ?? {}, + setExtraField, + }), + [driver, formData.extra, setExtraField], + ); + // ── helpers ── const loadSshConnectionsList = async () => { const result = await loadSshConnections(); @@ -2483,6 +2505,13 @@ export const NewConnectionModal = ({ /> + {/* Plugin-owned extra connection fields (opaque `extra` map) */} + + {/* User + Password */}
= new Set([ "settings.plugin.actions", "settings.plugin.before_settings", "connection-modal.connection_content", + "connection-modal.extra_fields", ]); /** diff --git a/src/utils/connections.ts b/src/utils/connections.ts index 259e783a4..053e48699 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -46,6 +46,37 @@ export interface ConnectionParams { k8s_port?: number; /** SQL run on every new connection to this data source (e.g. SET / set_config). */ startup_script?: string; + /** Opaque plugin-specific connection fields (e.g. `region` for a DynamoDB + * plugin). Persisted as-is and forwarded verbatim to the driver/plugin. */ + extra?: Record; +} + +/** + * Update one entry of the opaque `extra` connection fields map. + * + * Pure function — returns a new map, never mutates its input. + * - Blank keys are ignored (the input is returned unchanged). + * - An empty value removes the key, and the map collapsing to empty yields + * `undefined` so nothing extra is persisted or sent to the backend. + * + * @param extra - Current extra fields map (may be undefined) + * @param key - Field name owned by the plugin + * @param value - New value; empty string clears the field + */ +export function updateExtraField( + extra: Record | undefined, + key: string, + value: string, +): Record | undefined { + const trimmedKey = key.trim(); + if (!trimmedKey) return extra; + const next = { ...(extra ?? {}) }; + if (value === "") { + delete next[trimmedKey]; + } else { + next[trimmedKey] = value; + } + return Object.keys(next).length > 0 ? next : undefined; } /** diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index cd1079ee0..1ad8ace0e 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -22,6 +22,9 @@ interface ConnectionParams { ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; save_in_keychain?: boolean; + /** Opaque plugin-specific connection fields, persisted as-is and forwarded + * verbatim to the driver/plugin. */ + extra?: Record; } export interface SavedConnectionWithCredentials { diff --git a/tests/utils/connections.test.ts b/tests/utils/connections.test.ts index 6f750501d..d40d44061 100644 --- a/tests/utils/connections.test.ts +++ b/tests/utils/connections.test.ts @@ -7,6 +7,7 @@ import { generateConnectionName, connectionSubtitle, getCardClass, + updateExtraField, type ConnectionParams, type DatabaseDriver, } from '../../src/utils/connections'; @@ -515,4 +516,52 @@ describe('connections', () => { expect(generateConnectionName(params, makeRemoteCaps())).toBe('analytics@localhost'); }); }); + + describe('updateExtraField', () => { + it('should set a field on an undefined map', () => { + expect(updateExtraField(undefined, 'region', 'us-east-1')).toEqual({ + region: 'us-east-1', + }); + }); + + it('should set a field alongside existing entries', () => { + const result = updateExtraField({ profile: 'dev' }, 'region', 'eu-west-1'); + expect(result).toEqual({ profile: 'dev', region: 'eu-west-1' }); + }); + + it('should not mutate the input map', () => { + const input = { region: 'us-east-1' }; + updateExtraField(input, 'region', 'eu-west-1'); + expect(input).toEqual({ region: 'us-east-1' }); + }); + + it('should remove the key when the value is empty', () => { + const result = updateExtraField( + { region: 'us-east-1', profile: 'dev' }, + 'region', + '', + ); + expect(result).toEqual({ profile: 'dev' }); + }); + + it('should return undefined when the last entry is removed', () => { + expect(updateExtraField({ region: 'us-east-1' }, 'region', '')).toBeUndefined(); + }); + + it('should return undefined when removing from an undefined map', () => { + expect(updateExtraField(undefined, 'region', '')).toBeUndefined(); + }); + + it('should ignore blank keys', () => { + const input = { region: 'us-east-1' }; + expect(updateExtraField(input, ' ', 'value')).toBe(input); + expect(updateExtraField(undefined, '', 'value')).toBeUndefined(); + }); + + it('should trim the key', () => { + expect(updateExtraField(undefined, ' region ', 'us-east-1')).toEqual({ + region: 'us-east-1', + }); + }); + }); }); From 6d3189167ffe92bcf569e5abf00d3800dd69a81a Mon Sep 17 00:00:00 2001 From: Chris Chen <1163738+fuleinist@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:08:08 +1000 Subject: [PATCH 2/2] version Co-authored-by: Chris Chen <1163738+fuleinist@users.noreply.github.com> --- packages/plugin-api/package.json | 2 +- packages/plugin-api/src/version.ts | 2 +- src/contexts/PluginSlotProvider.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index a37fedc25..128ecffa1 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@tabularis/plugin-api", - "version": "0.2.0", + "version": "0.1.1", "description": "Public API surface for Tabularis plugin UI extensions.", "license": "Apache-2.0", "homepage": "https://github.com/TabularisDB/tabularis/tree/main/packages/plugin-api", diff --git a/packages/plugin-api/src/version.ts b/packages/plugin-api/src/version.ts index 2ab36f01d..e79983b3b 100644 --- a/packages/plugin-api/src/version.ts +++ b/packages/plugin-api/src/version.ts @@ -2,7 +2,7 @@ * API version of this package. Must match the version field of package.json. * Bump when the host API shape changes in a way that plugin bundles can observe. */ -export const API_VERSION = "0.2.0"; +export const API_VERSION = "0.1.1"; /** * Minimum Tabularis host version that exposes an API compatible with this package. diff --git a/src/contexts/PluginSlotProvider.tsx b/src/contexts/PluginSlotProvider.tsx index b5515d6ae..d65023b1d 100644 --- a/src/contexts/PluginSlotProvider.tsx +++ b/src/contexts/PluginSlotProvider.tsx @@ -24,7 +24,7 @@ interface PluginSlotProviderProps { * Keep in sync with `packages/plugin-api/src/version.ts` (API_VERSION). * Bump when the host API shape changes in a way plugin bundles can observe. */ -const HOST_API_VERSION = "0.2.0"; +const HOST_API_VERSION = "0.1.1"; let globalsExposed = false; function exposePluginGlobals() {