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}