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}