Skip to main content

hyperdb_mcp/
saved_queries.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Named read-only SQL queries exposed via the `save_query` / `delete_query`
5//! tools and the `hyper://queries/{name}/definition` +
6//! `hyper://queries/{name}/result` resources.
7//!
8//! Two [`SavedQueryStore`] implementations:
9//!
10//! * [`SessionStore`] — in-memory `HashMap` behind a `Mutex`. Used for
11//!   ephemeral servers (no `--workspace`) where persistence across restarts
12//!   is meaningless because the whole `.hyper` file is thrown away.
13//! * [`WorkspaceStore`] — backs onto a dedicated meta-table
14//!   `_hyperdb_saved_queries` inside the Hyper workspace. Chosen when a
15//!   `--workspace` path is configured so saved queries survive restarts
16//!   alongside the data they query.
17//!
18//! The server picks a store variant in `HyperMcpServer::new` and hands it
19//! to tool handlers through `Arc<dyn SavedQueryStore>`. Both variants share
20//! the same async-free API so call sites don't have to care which is in
21//! use.
22
23use crate::engine::Engine;
24use crate::error::{ErrorCode, McpError};
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex};
30
31/// The meta-table used by [`WorkspaceStore`] to persist named queries
32/// inside the `.hyper` workspace. The underscore prefix is a convention
33/// for "`HyperDB` internal" — users shouldn't query or mutate it directly.
34pub const SAVED_QUERIES_TABLE: &str = "_hyperdb_saved_queries";
35
36/// A named SQL query stored in the workspace.
37///
38/// Stored queries are *always* read-only at the SQL level — the save path
39/// enforces [`crate::engine::is_read_only_sql`] so accidentally persisting
40/// a destructive statement is impossible. The `created_at` field is
41/// populated server-side at save time.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct SavedQuery {
44    /// Human-friendly identifier; used as the path component in the
45    /// corresponding resource URIs.
46    pub name: String,
47    /// The SQL string that will be run when the `result` resource is read.
48    pub sql: String,
49    /// Optional free-form description of what the query answers.
50    pub description: Option<String>,
51    /// Server-side save time in UTC.
52    pub created_at: DateTime<Utc>,
53}
54
55impl SavedQuery {
56    /// JSON shape returned by `hyper://queries/{name}/definition`. Keeps
57    /// the representation consistent regardless of storage backend.
58    #[must_use]
59    pub fn to_json(&self) -> Value {
60        serde_json::json!({
61            "name": self.name,
62            "sql": self.sql,
63            "description": self.description,
64            "created_at": self.created_at.to_rfc3339(),
65        })
66    }
67}
68
69/// CRUD interface shared by both storage backends.
70///
71/// All methods take `&self` because both variants use interior mutability
72/// (`Mutex<HashMap>` or Hyper's own connection locking), so a single
73/// `Arc<dyn SavedQueryStore>` can be shared across the tool router and
74/// resource handler without further wrapping.
75///
76/// `engine` is passed in by the caller when the operation needs to touch
77/// Hyper; stores that don't need it (e.g. [`SessionStore`]) simply ignore
78/// the argument.
79pub trait SavedQueryStore: Send + Sync {
80    /// Persist a new query. Returns an `AlreadyExists`-class error
81    /// (currently `SchemaMismatch`, with a clear message) if `name` is
82    /// already in use — callers should `delete` first if overwriting is
83    /// intended.
84    ///
85    /// # Errors
86    ///
87    /// - Returns [`ErrorCode::InvalidArgument`] if a query with the same
88    ///   name already exists.
89    /// - Returns [`ErrorCode::InternalError`] for store-specific failures
90    ///   (poisoned mutex in [`SessionStore`], Hyper catalog errors in
91    ///   `CatalogStore`).
92    fn save(&self, engine: Option<&Engine>, query: SavedQuery) -> Result<(), McpError>;
93
94    /// Retrieve a single saved query by name, or `Ok(None)` if not found.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`ErrorCode::InternalError`] for store-specific failures
99    /// (poisoned mutex, catalog read failure, JSON decode failure of a
100    /// persisted row).
101    fn get(&self, engine: Option<&Engine>, name: &str) -> Result<Option<SavedQuery>, McpError>;
102
103    /// List all saved queries in alphabetical-by-name order. Empty
104    /// workspaces return `Ok(vec![])`, never an error.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`ErrorCode::InternalError`] for store-specific failures
109    /// (poisoned mutex, catalog read failure, JSON decode failure).
110    fn list(&self, engine: Option<&Engine>) -> Result<Vec<SavedQuery>, McpError>;
111
112    /// Remove a saved query by name. Returns `Ok(false)` if the name
113    /// wasn't present, `Ok(true)` if it was removed.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`ErrorCode::InternalError`] for store-specific failures
118    /// (poisoned mutex, Hyper delete statement failure).
119    fn delete(&self, engine: Option<&Engine>, name: &str) -> Result<bool, McpError>;
120}
121
122// --- SessionStore -----------------------------------------------------------
123
124/// In-memory [`SavedQueryStore`] for ephemeral workspaces. Entries live in
125/// a `Mutex<HashMap>` and vanish when the server process exits.
126///
127/// Intentional: reusing the saved query registry across restarts would be
128/// surprising when the workspace itself is ephemeral, since the underlying
129/// tables aren't persisted either.
130#[derive(Debug, Default)]
131pub struct SessionStore {
132    inner: Mutex<HashMap<String, SavedQuery>>,
133}
134
135impl SessionStore {
136    /// Construct an empty registry. Prefer wrapping in `Arc` immediately
137    /// — both the tool router and the resource handler need a handle.
138    #[must_use]
139    pub fn new() -> Self {
140        Self::default()
141    }
142}
143
144impl SavedQueryStore for SessionStore {
145    fn save(&self, _engine: Option<&Engine>, query: SavedQuery) -> Result<(), McpError> {
146        let mut guard = self
147            .inner
148            .lock()
149            .map_err(|_| McpError::new(ErrorCode::InternalError, "SessionStore lock poisoned"))?;
150        if guard.contains_key(&query.name) {
151            return Err(McpError::new(
152                ErrorCode::InvalidArgument,
153                format!(
154                    "A saved query named '{}' already exists. Delete it first with \
155                     delete_query if you intend to overwrite.",
156                    query.name
157                ),
158            ));
159        }
160        guard.insert(query.name.clone(), query);
161        Ok(())
162    }
163
164    fn get(&self, _engine: Option<&Engine>, name: &str) -> Result<Option<SavedQuery>, McpError> {
165        let guard = self
166            .inner
167            .lock()
168            .map_err(|_| McpError::new(ErrorCode::InternalError, "SessionStore lock poisoned"))?;
169        Ok(guard.get(name).cloned())
170    }
171
172    fn list(&self, _engine: Option<&Engine>) -> Result<Vec<SavedQuery>, McpError> {
173        let guard = self
174            .inner
175            .lock()
176            .map_err(|_| McpError::new(ErrorCode::InternalError, "SessionStore lock poisoned"))?;
177        let mut out: Vec<SavedQuery> = guard.values().cloned().collect();
178        out.sort_by(|a, b| a.name.cmp(&b.name));
179        Ok(out)
180    }
181
182    fn delete(&self, _engine: Option<&Engine>, name: &str) -> Result<bool, McpError> {
183        let mut guard = self
184            .inner
185            .lock()
186            .map_err(|_| McpError::new(ErrorCode::InternalError, "SessionStore lock poisoned"))?;
187        Ok(guard.remove(name).is_some())
188    }
189}
190
191// --- WorkspaceStore ---------------------------------------------------------
192
193/// Persistent [`SavedQueryStore`] backed by the `_hyperdb_saved_queries`
194/// meta-table inside the `.hyper` workspace. Rows round-trip through SQL
195/// parameter binding so saved queries containing quotes or backslashes are
196/// handled safely.
197///
198/// Lazy init: the meta-table is created on the first `save`/`list`/`get`/
199/// `delete` call, guarded by a `Mutex<bool>` so concurrent first-touches
200/// don't race against each other.
201#[derive(Debug, Default)]
202pub struct WorkspaceStore {
203    initialized: Mutex<bool>,
204}
205
206impl WorkspaceStore {
207    /// Construct an empty registry. The actual meta-table is created
208    /// lazily on the first CRUD call.
209    #[must_use]
210    pub fn new() -> Self {
211        Self::default()
212    }
213
214    /// Idempotently create the meta-table. Called at the top of every
215    /// public method to keep each entry point self-contained.
216    ///
217    /// The `initialized` flag is intentionally **not** reset on a
218    /// `ConnectionLost` reconnect. That's safe because `WorkspaceStore`
219    /// only ever backs persistent workspaces (ephemeral workspaces use
220    /// `SessionStore`), and the meta-table lives in the `.hyper` file
221    /// itself — a reconnect opens the same file and finds the table
222    /// already there. If this class is ever reused with an in-memory or
223    /// recreate-on-reconnect engine the flag will need to be tied to the
224    /// engine's lifetime.
225    fn ensure_table(&self, engine: &Engine) -> Result<(), McpError> {
226        let mut flag = self
227            .initialized
228            .lock()
229            .map_err(|_| McpError::new(ErrorCode::InternalError, "WorkspaceStore lock poisoned"))?;
230        if *flag {
231            return Ok(());
232        }
233        // `IF NOT EXISTS` means this is safe even across restarts where
234        // the meta-table already exists in the workspace file. No
235        // `PRIMARY KEY` because Hyper does not support indexes; name
236        // uniqueness is enforced application-side in [`Self::save`].
237        let ddl = format!(
238            "CREATE TABLE IF NOT EXISTS \"{SAVED_QUERIES_TABLE}\" (\
239                 name TEXT NOT NULL, \
240                 sql TEXT NOT NULL, \
241                 description TEXT, \
242                 created_at TIMESTAMP NOT NULL\
243             )"
244        );
245        engine.execute_command(&ddl)?;
246        *flag = true;
247        Ok(())
248    }
249}
250
251/// Escape a SQL string literal for direct concatenation. Only needed for
252/// the [`WorkspaceStore`] INSERTs where parameter binding isn't used
253/// because `execute_command` doesn't expose a bind path. `'` doubles to
254/// `''` per ANSI SQL; everything else passes through.
255fn sql_literal(s: &str) -> String {
256    format!("'{}'", s.replace('\'', "''"))
257}
258
259/// Materialize a row of the meta-table (returned by `execute_query_to_json`)
260/// into a `SavedQuery`. Times come back as RFC 3339 strings from the Hyper
261/// JSON renderer.
262fn row_to_saved_query(row: &Value) -> Result<SavedQuery, McpError> {
263    let name = row
264        .get("name")
265        .and_then(|v| v.as_str())
266        .ok_or_else(|| {
267            McpError::new(
268                ErrorCode::InternalError,
269                "_hyperdb_saved_queries row missing 'name'",
270            )
271        })?
272        .to_string();
273    let sql = row
274        .get("sql")
275        .and_then(|v| v.as_str())
276        .ok_or_else(|| {
277            McpError::new(
278                ErrorCode::InternalError,
279                "_hyperdb_saved_queries row missing 'sql'",
280            )
281        })?
282        .to_string();
283    let description = row
284        .get("description")
285        .and_then(|v| v.as_str())
286        .map(String::from);
287    let created_at_str = row.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
288    // Accept both RFC 3339 and the space-separated `YYYY-MM-DD HH:MM:SS[.fff]`
289    // shape Hyper emits for TIMESTAMP columns; fall back to "now" on parse
290    // failure rather than losing the whole row.
291    let created_at = DateTime::parse_from_rfc3339(created_at_str)
292        .map(|d| d.with_timezone(&Utc))
293        .or_else(|_| {
294            chrono::NaiveDateTime::parse_from_str(created_at_str, "%Y-%m-%d %H:%M:%S%.f")
295                .or_else(|_| {
296                    chrono::NaiveDateTime::parse_from_str(created_at_str, "%Y-%m-%d %H:%M:%S")
297                })
298                .map(|ndt| ndt.and_utc())
299                .map_err(|e| {
300                    McpError::new(
301                        ErrorCode::InternalError,
302                        format!("Could not parse created_at '{created_at_str}': {e}"),
303                    )
304                })
305        })?;
306
307    Ok(SavedQuery {
308        name,
309        sql,
310        description,
311        created_at,
312    })
313}
314
315impl SavedQueryStore for WorkspaceStore {
316    fn save(&self, engine: Option<&Engine>, query: SavedQuery) -> Result<(), McpError> {
317        let engine = engine.ok_or_else(|| {
318            McpError::new(
319                ErrorCode::InternalError,
320                "WorkspaceStore requires an engine handle",
321            )
322        })?;
323        self.ensure_table(engine)?;
324
325        // Up-front existence check — clearer error than Hyper's raw PK
326        // violation message, and matches SessionStore's behaviour.
327        let existing_sql = format!(
328            "SELECT name FROM \"{SAVED_QUERIES_TABLE}\" WHERE name = {}",
329            sql_literal(&query.name)
330        );
331        let rows = engine.execute_query_to_json(&existing_sql)?;
332        if !rows.is_empty() {
333            return Err(McpError::new(
334                ErrorCode::InvalidArgument,
335                format!(
336                    "A saved query named '{}' already exists. Delete it first with \
337                     delete_query if you intend to overwrite.",
338                    query.name
339                ),
340            ));
341        }
342
343        let description_sql = match &query.description {
344            Some(d) => sql_literal(d),
345            None => "NULL".into(),
346        };
347        let insert_sql = format!(
348            "INSERT INTO \"{SAVED_QUERIES_TABLE}\" (name, sql, description, created_at) \
349             VALUES ({name}, {sql}, {desc}, TIMESTAMP {ts})",
350            name = sql_literal(&query.name),
351            sql = sql_literal(&query.sql),
352            desc = description_sql,
353            // Hyper parses `TIMESTAMP 'YYYY-MM-DD HH:MM:SS[.fff]'` literals;
354            // strip the trailing "Z" that RFC 3339 adds.
355            ts = sql_literal(&query.created_at.format("%Y-%m-%d %H:%M:%S%.6f").to_string()),
356        );
357        engine.execute_command(&insert_sql)?;
358        Ok(())
359    }
360
361    fn get(&self, engine: Option<&Engine>, name: &str) -> Result<Option<SavedQuery>, McpError> {
362        let engine = engine.ok_or_else(|| {
363            McpError::new(
364                ErrorCode::InternalError,
365                "WorkspaceStore requires an engine handle",
366            )
367        })?;
368        self.ensure_table(engine)?;
369        let sql = format!(
370            "SELECT name, sql, description, created_at \
371             FROM \"{SAVED_QUERIES_TABLE}\" WHERE name = {}",
372            sql_literal(name)
373        );
374        let rows = engine.execute_query_to_json(&sql)?;
375        match rows.first() {
376            Some(row) => Ok(Some(row_to_saved_query(row)?)),
377            None => Ok(None),
378        }
379    }
380
381    fn list(&self, engine: Option<&Engine>) -> Result<Vec<SavedQuery>, McpError> {
382        let engine = engine.ok_or_else(|| {
383            McpError::new(
384                ErrorCode::InternalError,
385                "WorkspaceStore requires an engine handle",
386            )
387        })?;
388        self.ensure_table(engine)?;
389        let sql = format!(
390            "SELECT name, sql, description, created_at \
391             FROM \"{SAVED_QUERIES_TABLE}\" ORDER BY name"
392        );
393        let rows = engine.execute_query_to_json(&sql)?;
394        rows.iter().map(row_to_saved_query).collect()
395    }
396
397    fn delete(&self, engine: Option<&Engine>, name: &str) -> Result<bool, McpError> {
398        let engine = engine.ok_or_else(|| {
399            McpError::new(
400                ErrorCode::InternalError,
401                "WorkspaceStore requires an engine handle",
402            )
403        })?;
404        self.ensure_table(engine)?;
405        let sql = format!(
406            "DELETE FROM \"{SAVED_QUERIES_TABLE}\" WHERE name = {}",
407            sql_literal(name)
408        );
409        let affected = engine.execute_command(&sql)?;
410        Ok(affected > 0)
411    }
412}
413
414// --- Factory ----------------------------------------------------------------
415
416/// Build the right store for a given workspace mode.
417///
418/// `Some(path)` → [`WorkspaceStore`] (persisted in the `.hyper` file).
419/// `None`       → [`SessionStore`] (in-memory, dies with the process).
420#[must_use]
421pub fn build_store(workspace_path: Option<&str>) -> Arc<dyn SavedQueryStore> {
422    if workspace_path.is_some() {
423        Arc::new(WorkspaceStore::new())
424    } else {
425        Arc::new(SessionStore::new())
426    }
427}