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    /// Fully-qualified table reference inside the persistent attachment.
215    /// Saved queries are user reference material — they belong with
216    /// curated, long-lived data, which lives in the persistent DB.
217    fn qualified_table() -> String {
218        format!(
219            "\"{}\".\"public\".\"{}\"",
220            Engine::PERSISTENT_ALIAS,
221            SAVED_QUERIES_TABLE
222        )
223    }
224
225    /// Idempotently create the meta-table inside the persistent
226    /// attachment. Called at the top of every public method to keep
227    /// each entry point self-contained.
228    ///
229    /// The `initialized` flag is intentionally **not** reset on a
230    /// `ConnectionLost` reconnect. That's safe because `WorkspaceStore`
231    /// only ever backs persistent attachments (ephemeral-only sessions
232    /// use `SessionStore`), and the meta-table lives in the `.hyper`
233    /// file itself — a reconnect opens the same file and finds the table
234    /// already there.
235    fn ensure_table(&self, engine: &Engine) -> Result<(), McpError> {
236        let mut flag = self
237            .initialized
238            .lock()
239            .map_err(|_| McpError::new(ErrorCode::InternalError, "WorkspaceStore lock poisoned"))?;
240        if *flag {
241            return Ok(());
242        }
243        // `IF NOT EXISTS` means this is safe even across restarts where
244        // the meta-table already exists in the workspace file. No
245        // `PRIMARY KEY` because Hyper does not support indexes; name
246        // uniqueness is enforced application-side in [`Self::save`].
247        let ddl = format!(
248            "CREATE TABLE IF NOT EXISTS {table} (\
249                 name TEXT NOT NULL, \
250                 sql TEXT NOT NULL, \
251                 description TEXT, \
252                 created_at TIMESTAMP NOT NULL\
253             )",
254            table = Self::qualified_table()
255        );
256        engine.execute_command(&ddl)?;
257        *flag = true;
258        Ok(())
259    }
260}
261
262/// Escape a SQL string literal for direct concatenation. Only needed for
263/// the [`WorkspaceStore`] INSERTs where parameter binding isn't used
264/// because `execute_command` doesn't expose a bind path. `'` doubles to
265/// `''` per ANSI SQL; everything else passes through.
266fn sql_literal(s: &str) -> String {
267    format!("'{}'", s.replace('\'', "''"))
268}
269
270/// Materialize a row of the meta-table (returned by `execute_query_to_json`)
271/// into a `SavedQuery`. Times come back as RFC 3339 strings from the Hyper
272/// JSON renderer.
273fn row_to_saved_query(row: &Value) -> Result<SavedQuery, McpError> {
274    let name = row
275        .get("name")
276        .and_then(|v| v.as_str())
277        .ok_or_else(|| {
278            McpError::new(
279                ErrorCode::InternalError,
280                "_hyperdb_saved_queries row missing 'name'",
281            )
282        })?
283        .to_string();
284    let sql = row
285        .get("sql")
286        .and_then(|v| v.as_str())
287        .ok_or_else(|| {
288            McpError::new(
289                ErrorCode::InternalError,
290                "_hyperdb_saved_queries row missing 'sql'",
291            )
292        })?
293        .to_string();
294    let description = row
295        .get("description")
296        .and_then(|v| v.as_str())
297        .map(String::from);
298    let created_at_str = row.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
299    // Accept both RFC 3339 and the space-separated `YYYY-MM-DD HH:MM:SS[.fff]`
300    // shape Hyper emits for TIMESTAMP columns; fall back to "now" on parse
301    // failure rather than losing the whole row.
302    let created_at = DateTime::parse_from_rfc3339(created_at_str)
303        .map(|d| d.with_timezone(&Utc))
304        .or_else(|_| {
305            chrono::NaiveDateTime::parse_from_str(created_at_str, "%Y-%m-%d %H:%M:%S%.f")
306                .or_else(|_| {
307                    chrono::NaiveDateTime::parse_from_str(created_at_str, "%Y-%m-%d %H:%M:%S")
308                })
309                .map(|ndt| ndt.and_utc())
310                .map_err(|e| {
311                    McpError::new(
312                        ErrorCode::InternalError,
313                        format!("Could not parse created_at '{created_at_str}': {e}"),
314                    )
315                })
316        })?;
317
318    Ok(SavedQuery {
319        name,
320        sql,
321        description,
322        created_at,
323    })
324}
325
326impl SavedQueryStore for WorkspaceStore {
327    fn save(&self, engine: Option<&Engine>, query: SavedQuery) -> Result<(), McpError> {
328        let engine = engine.ok_or_else(|| {
329            McpError::new(
330                ErrorCode::InternalError,
331                "WorkspaceStore requires an engine handle",
332            )
333        })?;
334        self.ensure_table(engine)?;
335        let table = Self::qualified_table();
336
337        // Up-front existence check — clearer error than Hyper's raw PK
338        // violation message, and matches SessionStore's behaviour.
339        let existing_sql = format!(
340            "SELECT name FROM {table} WHERE name = {}",
341            sql_literal(&query.name)
342        );
343        let rows = engine.execute_query_to_json(&existing_sql)?;
344        if !rows.is_empty() {
345            return Err(McpError::new(
346                ErrorCode::InvalidArgument,
347                format!(
348                    "A saved query named '{}' already exists. Delete it first with \
349                     delete_query if you intend to overwrite.",
350                    query.name
351                ),
352            ));
353        }
354
355        let description_sql = match &query.description {
356            Some(d) => sql_literal(d),
357            None => "NULL".into(),
358        };
359        let insert_sql = format!(
360            "INSERT INTO {table} (name, sql, description, created_at) \
361             VALUES ({name}, {sql}, {desc}, TIMESTAMP {ts})",
362            name = sql_literal(&query.name),
363            sql = sql_literal(&query.sql),
364            desc = description_sql,
365            // Hyper parses `TIMESTAMP 'YYYY-MM-DD HH:MM:SS[.fff]'` literals;
366            // strip the trailing "Z" that RFC 3339 adds.
367            ts = sql_literal(&query.created_at.format("%Y-%m-%d %H:%M:%S%.6f").to_string()),
368        );
369        engine.execute_command(&insert_sql)?;
370        Ok(())
371    }
372
373    fn get(&self, engine: Option<&Engine>, name: &str) -> Result<Option<SavedQuery>, McpError> {
374        let engine = engine.ok_or_else(|| {
375            McpError::new(
376                ErrorCode::InternalError,
377                "WorkspaceStore requires an engine handle",
378            )
379        })?;
380        self.ensure_table(engine)?;
381        let sql = format!(
382            "SELECT name, sql, description, created_at \
383             FROM {table} WHERE name = {}",
384            sql_literal(name),
385            table = Self::qualified_table(),
386        );
387        let rows = engine.execute_query_to_json(&sql)?;
388        match rows.first() {
389            Some(row) => Ok(Some(row_to_saved_query(row)?)),
390            None => Ok(None),
391        }
392    }
393
394    fn list(&self, engine: Option<&Engine>) -> Result<Vec<SavedQuery>, McpError> {
395        let engine = engine.ok_or_else(|| {
396            McpError::new(
397                ErrorCode::InternalError,
398                "WorkspaceStore requires an engine handle",
399            )
400        })?;
401        self.ensure_table(engine)?;
402        let sql = format!(
403            "SELECT name, sql, description, created_at \
404             FROM {table} ORDER BY name",
405            table = Self::qualified_table(),
406        );
407        let rows = engine.execute_query_to_json(&sql)?;
408        rows.iter().map(row_to_saved_query).collect()
409    }
410
411    fn delete(&self, engine: Option<&Engine>, name: &str) -> Result<bool, McpError> {
412        let engine = engine.ok_or_else(|| {
413            McpError::new(
414                ErrorCode::InternalError,
415                "WorkspaceStore requires an engine handle",
416            )
417        })?;
418        self.ensure_table(engine)?;
419        let sql = format!(
420            "DELETE FROM {table} WHERE name = {}",
421            sql_literal(name),
422            table = Self::qualified_table(),
423        );
424        let affected = engine.execute_command(&sql)?;
425        Ok(affected > 0)
426    }
427}
428
429// --- Factory ----------------------------------------------------------------
430
431/// Build the right store for a given workspace mode.
432///
433/// `Some(path)` → [`WorkspaceStore`] (persisted in the `.hyper` file).
434/// `None`       → [`SessionStore`] (in-memory, dies with the process).
435#[must_use]
436pub fn build_store(workspace_path: Option<&str>) -> Arc<dyn SavedQueryStore> {
437    if workspace_path.is_some() {
438        Arc::new(WorkspaceStore::new())
439    } else {
440        Arc::new(SessionStore::new())
441    }
442}