Skip to main content

heliosdb_proxy/plugins/
mod.rs

1//! WASM Plugin System
2//!
3//! Feature 11: Extensible plugin system using WebAssembly for safe,
4//! sandboxed, high-performance extensibility.
5//!
6//! # Overview
7//!
8//! The WASM plugin system enables custom:
9//! - Authentication schemes
10//! - Query transformations
11//! - Caching strategies
12//! - Routing decisions
13//! - Metrics collection
14//!
15//! # Architecture
16//!
17//! ```text
18//! ┌─────────────────────────────────────────────────┐
19//! │              WASM PLUGIN RUNTIME                 │
20//! │  ┌──────────────────────────────────────────┐   │
21//! │  │ Plugin Manager                           │   │
22//! │  │ - Load/unload plugins                    │   │
23//! │  │ - Version management                     │   │
24//! │  │ - Health monitoring                      │   │
25//! │  └──────────────────────────────────────────┘   │
26//! │  ┌─────────────────┬─────────────────┐          │
27//! │  │     Plugin A    │     Plugin B    │          │
28//! │  │     (.wasm)     │     (.wasm)     │          │
29//! │  └─────────────────┴─────────────────┘          │
30//! │  ┌──────────────────────────────────────┐      │
31//! │  │ Host Functions (Secure API)          │      │
32//! │  │ - Query execution                    │      │
33//! │  │ - Cache access                        │      │
34//! │  │ - Metrics / Logging                  │      │
35//! │  └──────────────────────────────────────┘      │
36//! └─────────────────────────────────────────────────┘
37//! ```
38
39pub mod config;
40pub mod host_functions;
41pub mod host_imports;
42pub mod hot_reload;
43pub mod loader;
44pub mod metrics;
45pub mod runtime;
46pub mod sandbox;
47
48pub use config::{PluginConfig, PluginRuntimeConfig, PluginRuntimeConfigBuilder};
49pub use host_functions::HostFunctionRegistry;
50pub use hot_reload::{HotReloader, ReloadError, ReloadEvent};
51pub use loader::{PluginLoadError, PluginLoader, PluginManifest, SignatureVerifier};
52pub use metrics::{HookLatency, PluginMetrics, PluginStats};
53pub use runtime::{LoadedPlugin, PluginError, PluginState, WasmPluginRuntime};
54pub use sandbox::{Permission, PluginSandbox, ResourceLimits, SecurityPolicy};
55
56use dashmap::DashMap;
57use parking_lot::RwLock;
58use std::collections::HashMap;
59use std::sync::Arc;
60use std::time::Duration;
61
62/// Plugin metadata
63#[derive(Debug, Clone)]
64pub struct PluginMetadata {
65    /// Plugin name
66    pub name: String,
67
68    /// Version string
69    pub version: String,
70
71    /// Description
72    pub description: String,
73
74    /// Author
75    pub author: String,
76
77    /// Supported hooks
78    pub hooks: Vec<HookType>,
79
80    /// Required permissions
81    pub permissions: Vec<Permission>,
82
83    /// Minimum memory requirement
84    pub min_memory: usize,
85
86    /// Maximum memory requirement
87    pub max_memory: usize,
88}
89
90impl Default for PluginMetadata {
91    fn default() -> Self {
92        Self {
93            name: String::new(),
94            version: "0.0.0".to_string(),
95            description: String::new(),
96            author: String::new(),
97            hooks: Vec::new(),
98            permissions: Vec::new(),
99            min_memory: 1024 * 1024,      // 1MB
100            max_memory: 64 * 1024 * 1024, // 64MB
101        }
102    }
103}
104
105/// Hook types supported by plugins
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub enum HookType {
108    /// Before query execution
109    PreQuery,
110
111    /// After query execution
112    PostQuery,
113
114    /// Authentication
115    Authenticate,
116
117    /// Authorization
118    Authorize,
119
120    /// Cache lookup
121    CacheGet,
122
123    /// Cache store
124    CacheSet,
125
126    /// Routing decision
127    Route,
128
129    /// Query rewriting
130    Rewrite,
131
132    /// Metrics collection
133    Metrics,
134
135    /// On connection
136    OnConnect,
137
138    /// On disconnect
139    OnDisconnect,
140
141    /// Custom hook
142    Custom,
143}
144
145impl HookType {
146    /// Get the export function name for this hook
147    pub fn export_name(&self) -> &'static str {
148        match self {
149            HookType::PreQuery => "pre_query",
150            HookType::PostQuery => "post_query",
151            HookType::Authenticate => "authenticate",
152            HookType::Authorize => "authorize",
153            HookType::CacheGet => "cache_get",
154            HookType::CacheSet => "cache_set",
155            HookType::Route => "route",
156            HookType::Rewrite => "rewrite",
157            HookType::Metrics => "metrics",
158            HookType::OnConnect => "on_connect",
159            HookType::OnDisconnect => "on_disconnect",
160            HookType::Custom => "custom_hook",
161        }
162    }
163
164    /// Parse from string
165    #[allow(clippy::should_implement_trait)]
166    pub fn from_str(s: &str) -> Option<Self> {
167        match s.to_lowercase().as_str() {
168            "pre_query" | "prequery" => Some(HookType::PreQuery),
169            "post_query" | "postquery" => Some(HookType::PostQuery),
170            "authenticate" | "auth" => Some(HookType::Authenticate),
171            "authorize" => Some(HookType::Authorize),
172            "cache_get" | "cacheget" => Some(HookType::CacheGet),
173            "cache_set" | "cacheset" => Some(HookType::CacheSet),
174            "route" | "routing" => Some(HookType::Route),
175            "rewrite" => Some(HookType::Rewrite),
176            "metrics" => Some(HookType::Metrics),
177            "on_connect" | "connect" => Some(HookType::OnConnect),
178            "on_disconnect" | "disconnect" => Some(HookType::OnDisconnect),
179            "custom" => Some(HookType::Custom),
180            _ => None,
181        }
182    }
183}
184
185/// Hook context passed to plugins
186#[derive(Debug, Clone, serde::Serialize)]
187pub struct HookContext {
188    /// Request ID
189    pub request_id: String,
190
191    /// Client ID
192    pub client_id: Option<String>,
193
194    /// User identity
195    pub identity: Option<String>,
196
197    /// Current database
198    pub database: Option<String>,
199
200    /// Current branch
201    pub branch: Option<String>,
202
203    /// Additional attributes
204    pub attributes: HashMap<String, String>,
205}
206
207impl Default for HookContext {
208    fn default() -> Self {
209        Self {
210            request_id: uuid::Uuid::new_v4().to_string(),
211            client_id: None,
212            identity: None,
213            database: None,
214            branch: None,
215            attributes: HashMap::new(),
216        }
217    }
218}
219
220/// Query context for query-related hooks
221#[derive(Debug, Clone)]
222pub struct QueryContext {
223    /// The SQL query
224    pub query: String,
225
226    /// Normalized query (for fingerprinting)
227    pub normalized: String,
228
229    /// Tables referenced
230    pub tables: Vec<String>,
231
232    /// Is read-only query
233    pub is_read_only: bool,
234
235    /// Hook context
236    pub hook_context: HookContext,
237}
238
239/// Result of a pre-query hook
240#[derive(Debug, Clone)]
241pub enum PreQueryResult {
242    /// Continue with query
243    Continue,
244
245    /// Rewrite the query
246    Rewrite(String),
247
248    /// Block the query
249    Block(String),
250
251    /// Return cached result
252    Cached(Vec<u8>),
253}
254
255/// Outcome passed to post-query hooks.
256///
257/// Observer-only — post hooks may not change the result that has already
258/// gone back to the client. Useful for audit logs, metrics, and async
259/// downstream signalling.
260#[derive(Debug, Clone, serde::Serialize)]
261pub struct PostQueryOutcome {
262    /// Whether the query completed successfully
263    pub success: bool,
264
265    /// Backend node the query was routed to (if any)
266    pub target_node: Option<String>,
267
268    /// Wall-clock execution time in microseconds
269    pub elapsed_us: u64,
270
271    /// Response size in bytes (including all protocol framing)
272    pub response_bytes: u64,
273
274    /// Error message if the query failed
275    pub error: Option<String>,
276}
277
278/// Result of authentication hook
279#[derive(Debug, Clone)]
280pub enum AuthResult {
281    /// Authentication successful
282    Success(Identity),
283
284    /// Authentication failed
285    Denied(String),
286
287    /// Defer to next authenticator
288    Defer,
289}
290
291/// User identity from authentication
292#[derive(Debug, Clone, Default)]
293pub struct Identity {
294    /// User ID
295    pub user_id: String,
296
297    /// Username
298    pub username: String,
299
300    /// Roles
301    pub roles: Vec<String>,
302
303    /// Tenant ID
304    pub tenant_id: Option<String>,
305
306    /// Additional claims
307    pub claims: HashMap<String, String>,
308}
309
310/// Result of routing hook
311#[derive(Debug, Clone)]
312pub enum RouteResult {
313    /// Use default routing
314    Default,
315
316    /// Route to specific node
317    Node(String),
318
319    /// Route to primary
320    Primary,
321
322    /// Route to any standby
323    Standby,
324
325    /// Route to specific branch
326    Branch(String),
327
328    /// Reject the query with the given reason. Reaches the client as a
329    /// PostgreSQL ErrorResponse (severity ERROR, SQLSTATE 42000)
330    /// followed by ReadyForQuery — same wire shape as
331    /// `PreQueryResult::Block` so clients see one consistent error
332    /// path regardless of which hook rejected.
333    Block(String),
334}
335
336/// Plugin manager for coordinating all plugins
337pub struct PluginManager {
338    /// Runtime for WASM execution
339    runtime: Arc<WasmPluginRuntime>,
340
341    /// Loaded plugins by name
342    plugins: DashMap<String, Arc<LoadedPlugin>>,
343
344    /// Hooks registry
345    hooks: RwLock<HashMap<HookType, Vec<String>>>,
346
347    /// Configuration
348    #[allow(dead_code)]
349    config: PluginRuntimeConfig,
350
351    /// Hot reloader (if enabled)
352    hot_reloader: Option<HotReloader>,
353
354    /// Metrics collector
355    metrics: Arc<PluginMetrics>,
356}
357
358impl PluginManager {
359    /// Create a new plugin manager
360    pub fn new(config: PluginRuntimeConfig) -> Result<Self, PluginError> {
361        let runtime = Arc::new(WasmPluginRuntime::new(&config)?);
362        let metrics = Arc::new(PluginMetrics::new());
363
364        let hot_reloader = if config.hot_reload {
365            Some(HotReloader::new(&config.plugin_dir)?)
366        } else {
367            None
368        };
369
370        Ok(Self {
371            runtime,
372            plugins: DashMap::new(),
373            hooks: RwLock::new(HashMap::new()),
374            config,
375            hot_reloader,
376            metrics,
377        })
378    }
379
380    /// Load a plugin from file. When the runtime config sets a
381    /// `trust_root`, attaches a SignatureVerifier so every load
382    /// requires a matching `.sig` sidecar (FU-23 + FU-24).
383    pub fn load_plugin(&self, path: &std::path::Path) -> Result<(), PluginError> {
384        let mut loader = PluginLoader::new();
385        if let Some(ref dir) = self.runtime.config().trust_root {
386            let verifier = SignatureVerifier::from_trust_root(dir)
387                .map_err(|e| PluginError::LoadError(e.to_string()))?;
388            loader = loader.with_signature_verifier(verifier);
389        }
390        let (manifest, wasm_bytes) = loader.load(path)?;
391
392        let plugin = self.runtime.instantiate(&manifest, &wasm_bytes)?;
393        let plugin = Arc::new(plugin);
394
395        // Register hooks
396        {
397            let mut hooks = self.hooks.write();
398            for hook in &manifest.hooks {
399                hooks.entry(*hook).or_default().push(manifest.name.clone());
400            }
401        }
402
403        self.plugins.insert(manifest.name.clone(), plugin);
404
405        tracing::info!(
406            plugin = %manifest.name,
407            version = %manifest.version,
408            hooks = ?manifest.hooks,
409            "Plugin loaded"
410        );
411
412        Ok(())
413    }
414
415    /// Unload a plugin
416    pub fn unload_plugin(&self, name: &str) -> Result<(), PluginError> {
417        if let Some((_, plugin)) = self.plugins.remove(name) {
418            // Remove from hooks registry
419            let mut hooks = self.hooks.write();
420            for hook_plugins in hooks.values_mut() {
421                hook_plugins.retain(|p| p != name);
422            }
423
424            // Call plugin's on_unload if it exists
425            if let Err(e) = self.runtime.call_hook(&plugin, HookType::OnDisconnect, &[]) {
426                tracing::warn!(plugin = %name, error = %e, "Error calling on_unload");
427            }
428
429            tracing::info!(plugin = %name, "Plugin unloaded");
430        }
431
432        Ok(())
433    }
434
435    /// Reload a plugin
436    pub fn reload_plugin(&self, name: &str) -> Result<(), PluginError> {
437        if let Some(plugin) = self.plugins.get(name) {
438            let path = plugin.path.clone();
439            drop(plugin);
440
441            self.unload_plugin(name)?;
442            self.load_plugin(&path)?;
443        }
444
445        Ok(())
446    }
447
448    /// Cheap check whether any loaded plugin registered the given hook.
449    /// Lets the server's hook wrappers keep the no-plugin path free of
450    /// payload clones, SQL parsing, and context construction.
451    pub fn has_hook(&self, hook: HookType) -> bool {
452        self.hooks
453            .read()
454            .get(&hook)
455            .is_some_and(|names| !names.is_empty())
456    }
457
458    /// Execute pre-query hooks
459    pub fn execute_pre_query(&self, ctx: &QueryContext) -> PreQueryResult {
460        let hooks = self.hooks.read();
461        let plugin_names = hooks.get(&HookType::PreQuery).cloned().unwrap_or_default();
462        drop(hooks);
463
464        for plugin_name in plugin_names {
465            if let Some(plugin) = self.plugins.get(&plugin_name) {
466                let start = std::time::Instant::now();
467
468                match self.runtime.call_pre_query(&plugin, ctx) {
469                    Ok(result) => {
470                        self.metrics.record_hook_call(
471                            &plugin_name,
472                            HookType::PreQuery,
473                            start.elapsed(),
474                            true,
475                        );
476
477                        match result {
478                            PreQueryResult::Continue => continue,
479                            other => return other,
480                        }
481                    }
482                    Err(e) => {
483                        self.metrics.record_hook_call(
484                            &plugin_name,
485                            HookType::PreQuery,
486                            start.elapsed(),
487                            false,
488                        );
489                        tracing::warn!(
490                            plugin = %plugin_name,
491                            error = %e,
492                            "Pre-query hook failed"
493                        );
494                    }
495                }
496            }
497        }
498
499        PreQueryResult::Continue
500    }
501
502    /// Execute post-query hooks.
503    ///
504    /// Fan-out notification to every registered PostQuery plugin. Unlike
505    /// `execute_pre_query`, no plugin can short-circuit the others — post
506    /// hooks are observer-only (logging, metrics, audit). Errors from any
507    /// plugin are logged but never block completion.
508    pub fn execute_post_query(&self, ctx: &QueryContext, outcome: &PostQueryOutcome) {
509        let hooks = self.hooks.read();
510        let plugin_names = hooks.get(&HookType::PostQuery).cloned().unwrap_or_default();
511        drop(hooks);
512
513        if plugin_names.is_empty() {
514            return;
515        }
516
517        // Serialise (ctx, outcome) once and share the payload across every
518        // PostQuery plugin in the fan-out — it is identical for all of them.
519        let payload = match serde_json::to_vec(&(ctx, outcome)) {
520            Ok(v) => v,
521            Err(e) => {
522                tracing::warn!(error = %e, "Post-query serialisation failed");
523                return;
524            }
525        };
526
527        for plugin_name in plugin_names {
528            if let Some(plugin) = self.plugins.get(&plugin_name) {
529                let start = std::time::Instant::now();
530
531                match self
532                    .runtime
533                    .call_hook(&plugin, HookType::PostQuery, &payload)
534                {
535                    Ok(_) => {
536                        self.metrics.record_hook_call(
537                            &plugin_name,
538                            HookType::PostQuery,
539                            start.elapsed(),
540                            true,
541                        );
542                    }
543                    Err(e) => {
544                        self.metrics.record_hook_call(
545                            &plugin_name,
546                            HookType::PostQuery,
547                            start.elapsed(),
548                            false,
549                        );
550                        tracing::warn!(
551                            plugin = %plugin_name,
552                            error = %e,
553                            "Post-query hook failed"
554                        );
555                    }
556                }
557            }
558        }
559    }
560
561    /// Execute authentication hooks
562    pub fn execute_authenticate(&self, request: &AuthRequest) -> AuthResult {
563        let hooks = self.hooks.read();
564        let plugin_names = hooks
565            .get(&HookType::Authenticate)
566            .cloned()
567            .unwrap_or_default();
568        drop(hooks);
569
570        for plugin_name in plugin_names {
571            if let Some(plugin) = self.plugins.get(&plugin_name) {
572                let start = std::time::Instant::now();
573
574                match self.runtime.call_authenticate(&plugin, request) {
575                    Ok(result) => {
576                        self.metrics.record_hook_call(
577                            &plugin_name,
578                            HookType::Authenticate,
579                            start.elapsed(),
580                            true,
581                        );
582
583                        match result {
584                            AuthResult::Defer => continue,
585                            other => return other,
586                        }
587                    }
588                    Err(e) => {
589                        self.metrics.record_hook_call(
590                            &plugin_name,
591                            HookType::Authenticate,
592                            start.elapsed(),
593                            false,
594                        );
595                        tracing::warn!(
596                            plugin = %plugin_name,
597                            error = %e,
598                            "Authenticate hook failed"
599                        );
600                    }
601                }
602            }
603        }
604
605        AuthResult::Defer
606    }
607
608    /// Execute routing hooks
609    pub fn execute_route(&self, ctx: &QueryContext) -> RouteResult {
610        let hooks = self.hooks.read();
611        let plugin_names = hooks.get(&HookType::Route).cloned().unwrap_or_default();
612        drop(hooks);
613
614        for plugin_name in plugin_names {
615            if let Some(plugin) = self.plugins.get(&plugin_name) {
616                let start = std::time::Instant::now();
617
618                match self.runtime.call_route(&plugin, ctx) {
619                    Ok(result) => {
620                        self.metrics.record_hook_call(
621                            &plugin_name,
622                            HookType::Route,
623                            start.elapsed(),
624                            true,
625                        );
626
627                        match result {
628                            RouteResult::Default => continue,
629                            other => return other,
630                        }
631                    }
632                    Err(e) => {
633                        self.metrics.record_hook_call(
634                            &plugin_name,
635                            HookType::Route,
636                            start.elapsed(),
637                            false,
638                        );
639                        tracing::warn!(
640                            plugin = %plugin_name,
641                            error = %e,
642                            "Route hook failed"
643                        );
644                    }
645                }
646            }
647        }
648
649        RouteResult::Default
650    }
651
652    /// List loaded plugins
653    pub fn list_plugins(&self) -> Vec<PluginInfo> {
654        self.plugins
655            .iter()
656            .map(|entry| {
657                let plugin = entry.value();
658                let stats = self.metrics.get_plugin_stats(&plugin.metadata.name);
659
660                PluginInfo {
661                    name: plugin.metadata.name.clone(),
662                    version: plugin.metadata.version.clone(),
663                    description: plugin.metadata.description.clone(),
664                    hooks: plugin.metadata.hooks.clone(),
665                    state: plugin.state.clone(),
666                    stats,
667                }
668            })
669            .collect()
670    }
671
672    /// Get plugin metrics
673    pub fn get_metrics(&self) -> PluginManagerMetrics {
674        PluginManagerMetrics {
675            plugins_loaded: self.plugins.len(),
676            total_hook_calls: self.metrics.total_calls(),
677            total_errors: self.metrics.total_errors(),
678            avg_latency: self.metrics.avg_latency(),
679            plugins: self.list_plugins(),
680        }
681    }
682
683    /// Check for hot reload updates
684    pub fn check_updates(&self) -> Result<Vec<ReloadEvent>, PluginError> {
685        if let Some(ref reloader) = self.hot_reloader {
686            let events = reloader.check()?;
687
688            for event in &events {
689                match event {
690                    ReloadEvent::Modified(name) => {
691                        tracing::info!(plugin = %name, "Hot reloading plugin");
692                        if let Err(e) = self.reload_plugin(name) {
693                            tracing::error!(plugin = %name, error = %e, "Hot reload failed");
694                        }
695                    }
696                    ReloadEvent::Removed(name) => {
697                        tracing::info!(plugin = %name, "Plugin file removed, unloading");
698                        if let Err(e) = self.unload_plugin(name) {
699                            tracing::error!(plugin = %name, error = %e, "Unload failed");
700                        }
701                    }
702                    ReloadEvent::Added(path) => {
703                        tracing::info!(path = %path.display(), "New plugin detected, loading");
704                        if let Err(e) = self.load_plugin(path) {
705                            tracing::error!(path = %path.display(), error = %e, "Load failed");
706                        }
707                    }
708                }
709            }
710
711            Ok(events)
712        } else {
713            Ok(Vec::new())
714        }
715    }
716}
717
718/// Authentication request
719#[derive(Debug, Clone)]
720pub struct AuthRequest {
721    /// HTTP headers
722    pub headers: HashMap<String, String>,
723
724    /// Username (if provided)
725    pub username: Option<String>,
726
727    /// Password (if provided)
728    pub password: Option<String>,
729
730    /// Client IP
731    pub client_ip: String,
732
733    /// Target database
734    pub database: Option<String>,
735}
736
737/// Plugin information for listing
738#[derive(Debug, Clone)]
739pub struct PluginInfo {
740    /// Plugin name
741    pub name: String,
742
743    /// Version
744    pub version: String,
745
746    /// Description
747    pub description: String,
748
749    /// Supported hooks
750    pub hooks: Vec<HookType>,
751
752    /// Current state
753    pub state: PluginState,
754
755    /// Statistics
756    pub stats: PluginStats,
757}
758
759/// Plugin manager metrics
760#[derive(Debug, Clone)]
761pub struct PluginManagerMetrics {
762    /// Number of plugins loaded
763    pub plugins_loaded: usize,
764
765    /// Total hook calls
766    pub total_hook_calls: u64,
767
768    /// Total errors
769    pub total_errors: u64,
770
771    /// Average latency
772    pub avg_latency: Duration,
773
774    /// Per-plugin info
775    pub plugins: Vec<PluginInfo>,
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    #[test]
783    fn test_hook_type_export_name() {
784        assert_eq!(HookType::PreQuery.export_name(), "pre_query");
785        assert_eq!(HookType::Authenticate.export_name(), "authenticate");
786        assert_eq!(HookType::Route.export_name(), "route");
787    }
788
789    #[test]
790    fn test_hook_type_from_str() {
791        assert_eq!(HookType::from_str("pre_query"), Some(HookType::PreQuery));
792        assert_eq!(
793            HookType::from_str("authenticate"),
794            Some(HookType::Authenticate)
795        );
796        assert_eq!(HookType::from_str("unknown"), None);
797    }
798
799    #[test]
800    fn test_plugin_metadata_default() {
801        let meta = PluginMetadata::default();
802        assert!(meta.name.is_empty());
803        assert_eq!(meta.version, "0.0.0");
804        assert!(meta.hooks.is_empty());
805    }
806
807    #[test]
808    fn test_hook_context_default() {
809        let ctx = HookContext::default();
810        assert!(!ctx.request_id.is_empty());
811        assert!(ctx.client_id.is_none());
812    }
813
814    #[test]
815    fn test_pre_query_result() {
816        let result = PreQueryResult::Continue;
817        assert!(matches!(result, PreQueryResult::Continue));
818
819        let result = PreQueryResult::Block("blocked".to_string());
820        assert!(matches!(result, PreQueryResult::Block(_)));
821    }
822
823    #[test]
824    fn test_auth_result() {
825        let result = AuthResult::Denied("invalid".to_string());
826        assert!(matches!(result, AuthResult::Denied(_)));
827
828        let result = AuthResult::Defer;
829        assert!(matches!(result, AuthResult::Defer));
830    }
831
832    #[test]
833    fn test_route_result() {
834        let result = RouteResult::Default;
835        assert!(matches!(result, RouteResult::Default));
836
837        let result = RouteResult::Branch("test".to_string());
838        assert!(matches!(result, RouteResult::Branch(_)));
839    }
840
841    #[test]
842    fn test_identity_default() {
843        let identity = Identity::default();
844        assert!(identity.user_id.is_empty());
845        assert!(identity.roles.is_empty());
846        assert!(identity.tenant_id.is_none());
847    }
848
849    /// With no plugins registered, `execute_post_query` must be a silent
850    /// no-op — the proxy's post-query hook call site fires unconditionally
851    /// whenever a plugin manager exists, so "no hooks subscribed" must not
852    /// panic or take a lock it shouldn't.
853    #[test]
854    fn test_execute_post_query_no_plugins_is_noop() {
855        let config = PluginRuntimeConfig::default();
856        let pm = PluginManager::new(config).expect("construct PluginManager");
857
858        let ctx = QueryContext {
859            query: "SELECT 1".to_string(),
860            normalized: "SELECT 1".to_string(),
861            tables: Vec::new(),
862            is_read_only: true,
863            hook_context: HookContext::default(),
864        };
865        let outcome = PostQueryOutcome {
866            success: true,
867            target_node: Some("primary".to_string()),
868            elapsed_us: 42,
869            response_bytes: 128,
870            error: None,
871        };
872
873        // Must not panic; no plugins registered means this is pure no-op.
874        pm.execute_post_query(&ctx, &outcome);
875
876        // Metrics should remain empty — no hook was actually invoked.
877        let metrics = pm.get_metrics();
878        assert_eq!(metrics.plugins_loaded, 0);
879        assert_eq!(metrics.total_hook_calls, 0);
880    }
881
882    /// Same for `execute_pre_query` — the no-plugins default path must
883    /// yield `Continue` so the proxy's main loop forwards normally.
884    #[test]
885    fn test_execute_pre_query_no_plugins_returns_continue() {
886        let pm =
887            PluginManager::new(PluginRuntimeConfig::default()).expect("construct PluginManager");
888        let ctx = QueryContext {
889            query: "SELECT 1".to_string(),
890            normalized: "SELECT 1".to_string(),
891            tables: Vec::new(),
892            is_read_only: true,
893            hook_context: HookContext::default(),
894        };
895        assert!(matches!(
896            pm.execute_pre_query(&ctx),
897            PreQueryResult::Continue
898        ));
899    }
900
901    /// `PostQueryOutcome` must serialise cleanly — post-hook plugins
902    /// receive a JSON representation on the WASM boundary.
903    #[test]
904    fn test_post_query_outcome_serialisation() {
905        let outcome = PostQueryOutcome {
906            success: false,
907            target_node: None,
908            elapsed_us: 1234,
909            response_bytes: 0,
910            error: Some("backend timeout".to_string()),
911        };
912        let json = serde_json::to_string(&outcome).expect("serialise");
913        assert!(json.contains("\"success\":false"));
914        assert!(json.contains("\"elapsed_us\":1234"));
915        assert!(json.contains("backend timeout"));
916    }
917}