hyperdb_mcp/engine.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Core database engine that owns the `HyperProcess` and its connection.
5//!
6//! The [`Engine`] is the single point of contact with the Hyper database. It
7//! manages process startup, connection lifecycle, table DDL, query execution,
8//! and workspace metadata. All higher-level modules (ingest, export, server)
9//! operate through an `&Engine` reference.
10//!
11//! # Lazy Initialization and Connection Recovery
12//!
13//! The engine is lazily initialized by [`crate::server::HyperMcpServer`] on the
14//! first tool call (not during MCP handshake). This keeps the `initialize`
15//! response fast and avoids starting `hyperd` if the client never calls a tool.
16//!
17//! If the connection to `hyperd` is lost (crash, broken pipe, wire-protocol
18//! desync), the server's `crate::server::HyperMcpServer::with_engine` wrapper
19//! detects the [`crate::error::ErrorCode::ConnectionLost`] error, drops the
20//! engine, and transparently re-creates it on the next call. This auto-reconnect
21//! path covers both transport-level failures and the `"desynchronized"` state
22//! surfaced by the `hyper-client` layer's bounded drain.
23//!
24//! # Workspace Model
25//!
26//! Every session has an **ephemeral primary database** at
27//! `$TMPDIR/hyperdb-mcp-<pid>/scratch.hyper`. This is where unqualified
28//! tool calls land — exploratory loads, ad-hoc queries, scratch tables.
29//! It is created fresh on engine start and deleted (DETACH + remove) when
30//! the engine drops.
31//!
32//! When a persistent path is supplied (CLI `--persistent-db`, env var
33//! `HYPERDB_PERSISTENT_DB`, or the platform default), the engine records
34//! it; the [`crate::server::HyperMcpServer`] then ATTACHes that file under
35//! alias `"persistent"` after construction so the LLM can target it via
36//! the `database` parameter on data tools, or via `persist: true` on
37//! load tools. The persistent file lives across sessions.
38//!
39//! Passing `None` (or `--ephemeral-only` at the CLI) skips the persistent
40//! attachment; the only available database is the ephemeral primary plus
41//! any user-attached DBs.
42//!
43//! # Sync Calls in an Async Server
44//!
45//! All `Engine` methods are synchronous (blocking). The MCP server runs on a
46//! tokio runtime, but `hyperd` communication goes through the `hyperdb-api` crate's
47//! blocking `Connection` API. The `rmcp` framework spawns tool handlers on its
48//! own task pool, so blocking calls do not starve the async event loop. A future
49//! optimization could use `spawn_blocking` or an async connection API, but the
50//! current approach is correct and simple.
51
52use crate::daemon;
53use crate::error::{ErrorCode, McpError};
54use crate::schema::ColumnSchema;
55use hyperdb_api::{
56 escape_sql_path, Catalog, Connection, CreateMode, HyperProcess, Parameters, SqlType,
57};
58use serde_json::{json, Value};
59use std::path::{Path, PathBuf};
60use std::sync::atomic::{AtomicU64, Ordering};
61
62/// Per-process counter so multiple `Engine` instances in the same PID get
63/// distinct ephemeral directories (parallel test runners, embedded uses).
64static EPHEMERAL_SEQ: AtomicU64 = AtomicU64::new(0);
65
66/// Reserved alias under which the default persistent database is attached.
67/// Mirrored as [`Engine::PERSISTENT_ALIAS`] for the public API.
68const PERSISTENT_ALIAS: &str = "persistent";
69
70/// Outcome of [`attach_default_persistent`] — flags whether the file was
71/// freshly created so the catalog-seed step can fire (or skip).
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct PersistentAttachOutcome {
74 /// `true` when MCP just created the `.hyper` file as part of the
75 /// attach; `false` when the file already existed and we attached it
76 /// as-is.
77 pub file_was_created: bool,
78}
79
80/// Attach the persistent database under the reserved `"persistent"`
81/// alias on `connection`, creating the underlying `.hyper` file if it
82/// doesn't yet exist. Also pins `schema_search_path` to `primary_db_name`
83/// so unqualified SQL keeps routing to the ephemeral primary.
84fn attach_default_persistent(
85 connection: &Connection,
86 persistent_path: &Path,
87 primary_db_name: &str,
88) -> Result<PersistentAttachOutcome, McpError> {
89 let path_str = persistent_path.to_string_lossy();
90 let file_was_created = !persistent_path.exists();
91 if file_was_created {
92 let create_sql = format!(
93 "CREATE DATABASE IF NOT EXISTS {}",
94 escape_sql_path(&path_str)
95 );
96 connection.execute_command(&create_sql).map_err(|e| {
97 McpError::new(
98 ErrorCode::InternalError,
99 format!("Failed to create persistent database: {e}"),
100 )
101 })?;
102 }
103 let attach_sql = format!(
104 "ATTACH DATABASE {path} AS \"{alias}\"",
105 path = escape_sql_path(&path_str),
106 alias = PERSISTENT_ALIAS,
107 );
108 connection.execute_command(&attach_sql).map_err(|e| {
109 McpError::new(
110 ErrorCode::InternalError,
111 format!("Failed to attach persistent database: {e}"),
112 )
113 })?;
114 // Pin search_path to the primary so unqualified SQL keeps routing
115 // there even with the persistent attachment present. Mirrors the
116 // logic AttachRegistry uses for user-attached databases.
117 let pin_sql = format!(
118 "SET schema_search_path = '{}'",
119 primary_db_name.replace('\'', "''")
120 );
121 connection.execute_command(&pin_sql).map_err(|e| {
122 McpError::new(
123 ErrorCode::InternalError,
124 format!("Failed to pin schema_search_path: {e}"),
125 )
126 })?;
127 Ok(PersistentAttachOutcome { file_was_created })
128}
129
130/// File-stem of a `.hyper` path as the unqualified database name Hyper
131/// uses internally. Falls back to `"scratch"` if the stem can't be read.
132fn path_stem(path: &Path) -> String {
133 path.file_stem()
134 .and_then(|s| s.to_str())
135 .unwrap_or("scratch")
136 .to_string()
137}
138
139/// Owns a connection to `hyperd`, the ephemeral primary database, and an
140/// optional persistent attachment path. All SQL execution flows through
141/// this struct.
142///
143/// Two process modes:
144/// - **Local** — this engine owns the `HyperProcess` subprocess directly.
145/// - **Daemon** — a shared daemon manages `hyperd`; the engine only holds a connection.
146///
147/// Database layout:
148/// RAII guard that restores the `schema_search_path` to the primary
149/// database when dropped. Created by [`Engine::scoped_search_path`].
150/// If the restore fails, logs a warning — the engine mutex serializes
151/// calls so the stale path only persists until the next tool call's
152/// own `scoped_search_path` or until `with_engine` replaces the engine
153/// on a `ConnectionLost` error.
154#[derive(Debug)]
155pub struct ScopedSearchPath<'a> {
156 engine: &'a Engine,
157 restore_to: String,
158}
159
160impl Drop for ScopedSearchPath<'_> {
161 fn drop(&mut self) {
162 let sql = format!(
163 "SET schema_search_path = '{}'",
164 self.restore_to.replace('\'', "''")
165 );
166 if let Err(e) = self.engine.execute_command(&sql) {
167 tracing::warn!(
168 error = %e.message,
169 "failed to restore schema_search_path — next tool call may route incorrectly"
170 );
171 }
172 }
173}
174
175/// - The connection is *bound* to the ephemeral primary at
176/// [`Self::ephemeral_path`]. Unqualified SQL routes here.
177/// - When [`Self::persistent_path`] is `Some`, the server attaches that
178/// file as `"persistent"` after engine construction. When `None`, no
179/// persistent storage is available this session (`--ephemeral-only`).
180#[derive(Debug)]
181pub struct Engine {
182 /// `None` in daemon mode (the daemon owns the process).
183 hyper: Option<HyperProcess>,
184 /// Stored endpoint for daemon mode (the daemon advertises this).
185 daemon_endpoint: Option<String>,
186 /// The daemon's health port, if connected via daemon mode. `None` in local mode.
187 /// Used by the server's heartbeat logic to target the correct port (not a re-resolve,
188 /// which would break when scanning is enabled).
189 daemon_health_port: Option<u16>,
190 connection: Connection,
191 /// The primary database for this session. Lives in a temp dir and is
192 /// deleted on `Drop`.
193 ephemeral_path: PathBuf,
194 /// User-data persistent database. Attached under alias `"persistent"`
195 /// during [`Engine::new`]. `None` in `--ephemeral-only` mode.
196 persistent_path: Option<PathBuf>,
197 /// `true` when the persistent `.hyper` file was just created during
198 /// engine construction (so the catalog-seed step should fire). Reset
199 /// to `false` after the server consumes it via
200 /// [`Self::take_persistent_was_created`].
201 persistent_was_created: bool,
202 /// Cached "_table_catalog exists in `<alias>`" probes, keyed by
203 /// canonical alias (lowercase). Populated on first call to
204 /// [`Self::catalog_present_in`] for each `(engine, alias)` pair.
205 ///
206 /// Lives on the Engine because the catalog is per-engine-lifetime
207 /// (a `ConnectionLost` reconnect creates a fresh Engine, so the
208 /// cache resets at the right boundary). Detaching an alias clears
209 /// its entry via [`Self::clear_catalog_cache_for`] so a re-attach
210 /// to a different file/writability doesn't reuse a stale value.
211 /// `Some(false)` is cacheable too — once the catalog is confirmed
212 /// absent it stays absent for the rest of the engine's lifetime
213 /// unless explicitly cleared.
214 catalog_present_cache: std::sync::Mutex<std::collections::HashMap<String, bool>>,
215 log_dir: PathBuf,
216}
217
218impl Engine {
219 /// Create a new Engine. The connection is bound to a fresh ephemeral
220 /// primary in a temp directory. If `persistent_db_path` is `Some`,
221 /// the path is recorded so the server can ATTACH it post-construction;
222 /// passing `None` means `--ephemeral-only`.
223 ///
224 /// Connects to the shared daemon if available, falling back to a local `hyperd`.
225 ///
226 /// # Errors
227 ///
228 /// - Returns [`ErrorCode::PermissionDenied`] if the persistent parent
229 /// directory or the log directory cannot be created.
230 /// - Returns [`ErrorCode::InternalError`] if the ephemeral temp
231 /// directory cannot be created, if the `public` schema bootstrap
232 /// fails, or if the initial connection to `hyperd` fails.
233 /// - Returns [`ErrorCode::HyperdNotFound`] when [`HyperProcess::new`]
234 /// reports the `hyperd` executable is missing or unreachable via
235 /// `HYPERD_PATH`.
236 pub fn new(persistent_db_path: Option<String>) -> Result<Self, McpError> {
237 Self::new_with_mode(persistent_db_path, false)
238 }
239
240 /// Create an engine that bypasses the shared daemon and spawns a private `hyperd`.
241 ///
242 /// # Errors
243 /// Same as [`Self::new`].
244 pub fn new_no_daemon(persistent_db_path: Option<String>) -> Result<Self, McpError> {
245 Self::new_with_mode(persistent_db_path, true)
246 }
247
248 #[expect(
249 clippy::needless_pass_by_value,
250 reason = "Option<String> is consumed by the path-expansion logic below"
251 )]
252 fn new_with_mode(
253 persistent_db_path: Option<String>,
254 no_daemon: bool,
255 ) -> Result<Self, McpError> {
256 // Resolve persistent path (if requested) and pre-create its parent dir.
257 let persistent_path = match persistent_db_path.as_deref() {
258 Some(p) => {
259 let path = PathBuf::from(shellexpand_tilde(p));
260 if let Some(parent) = path.parent() {
261 std::fs::create_dir_all(parent).map_err(|e| {
262 McpError::new(
263 ErrorCode::PermissionDenied,
264 format!("Cannot create persistent-db directory: {e}"),
265 )
266 })?;
267 }
268 Some(path)
269 }
270 None => None,
271 };
272
273 // Always allocate a fresh ephemeral primary in a per-engine temp dir.
274 // The directory name combines the PID and a process-wide counter so
275 // multiple Engine instances in the same process (parallel tests,
276 // embedded uses, restart-after-ConnectionLost) never collide.
277 let seq = EPHEMERAL_SEQ.fetch_add(1, Ordering::Relaxed);
278 let ephemeral_dir =
279 std::env::temp_dir().join(format!("hyperdb-mcp-{}-{seq}", std::process::id()));
280 std::fs::create_dir_all(&ephemeral_dir).map_err(|e| {
281 McpError::new(
282 ErrorCode::InternalError,
283 format!("Cannot create ephemeral directory: {e}"),
284 )
285 })?;
286 let ephemeral_path = ephemeral_dir.join("scratch.hyper");
287
288 // Logs live next to the persistent file when one was supplied so
289 // operators find them in a stable location; otherwise next to the
290 // ephemeral primary.
291 let log_dir = resolve_log_dir(persistent_db_path.as_deref());
292 std::fs::create_dir_all(&log_dir).map_err(|e| {
293 McpError::new(
294 ErrorCode::PermissionDenied,
295 format!("Cannot create log directory {}: {e}", log_dir.display()),
296 )
297 })?;
298
299 // Try daemon mode first unless disabled
300 if !no_daemon {
301 if let Some(engine) =
302 Self::try_daemon_mode(&ephemeral_path, persistent_path.clone(), &log_dir)?
303 {
304 return Ok(engine);
305 }
306 }
307
308 // Fall back to spawning a local HyperProcess
309 let mut params = Parameters::new();
310 params.set("log_file_max_count", "2");
311 params.set("log_file_size_limit", "100M");
312 params.set("log_dir", log_dir.to_string_lossy().as_ref());
313
314 let hyper = HyperProcess::new(None, Some(¶ms)).map_err(|e| {
315 let msg = e.to_string();
316 if msg.contains("hyperd") || msg.contains("HYPERD_PATH") || msg.contains("No such file")
317 {
318 McpError::new(ErrorCode::HyperdNotFound, msg)
319 } else {
320 McpError::new(ErrorCode::InternalError, msg)
321 }
322 })?;
323
324 // Bind to the ephemeral primary. CreateAndReplace because a stale
325 // file in the per-pid temp dir from a crashed prior session would
326 // otherwise leak into this one.
327 let connection = Connection::new(&hyper, &ephemeral_path, CreateMode::CreateAndReplace)
328 .map_err(|e| {
329 McpError::new(ErrorCode::InternalError, format!("Failed to connect: {e}"))
330 })?;
331
332 bootstrap_public_schema(&connection)?;
333
334 let primary_db_name = path_stem(&ephemeral_path);
335 let persistent_was_created = Self::attach_persistent_if_present(
336 &connection,
337 persistent_path.as_deref(),
338 &primary_db_name,
339 )?;
340
341 Ok(Self {
342 hyper: Some(hyper),
343 daemon_endpoint: None,
344 daemon_health_port: None,
345 connection,
346 ephemeral_path,
347 persistent_path,
348 persistent_was_created,
349 catalog_present_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
350 log_dir,
351 })
352 }
353
354 /// If `persistent_path` is `Some`, attach the file under the reserved
355 /// `"persistent"` alias and pin the search path. Returns `true` if
356 /// the file was just created, `false` if it already existed or if
357 /// `persistent_path` is `None`.
358 fn attach_persistent_if_present(
359 connection: &Connection,
360 persistent_path: Option<&Path>,
361 primary_db_name: &str,
362 ) -> Result<bool, McpError> {
363 let Some(path) = persistent_path else {
364 return Ok(false);
365 };
366 let outcome = attach_default_persistent(connection, path, primary_db_name)?;
367 Ok(outcome.file_was_created)
368 }
369
370 /// Attempt to connect via the shared daemon. Returns `None` if the daemon
371 /// cannot be reached (falls back to local mode).
372 fn try_daemon_mode(
373 ephemeral_path: &Path,
374 persistent_path: Option<PathBuf>,
375 log_dir: &Path,
376 ) -> Result<Option<Self>, McpError> {
377 let info = match daemon::spawn::ensure_daemon(daemon::discovery::resolve_port_scan()) {
378 Ok(info) => info,
379 Err(e) => {
380 tracing::debug!(error = %e, "daemon unavailable, falling back to local mode");
381 return Ok(None);
382 }
383 };
384
385 let endpoint = &info.hyperd_endpoint;
386 // CreateAndReplace: same rationale as the local path — a per-pid
387 // temp file from a crashed prior session shouldn't leak in.
388 let connection = Connection::connect(
389 endpoint,
390 &ephemeral_path.to_string_lossy(),
391 CreateMode::CreateAndReplace,
392 )
393 .map_err(|e| {
394 // The daemon's discovery file points at this endpoint but we can't
395 // reach it — hyperd is likely dead. Tell the daemon so it can
396 // restart it on its next monitor tick.
397 daemon::health::report_hyperd_error_to_daemon();
398 McpError::new(
399 ErrorCode::InternalError,
400 format!("Failed to connect to daemon hyperd at {endpoint}: {e}"),
401 )
402 })?;
403
404 bootstrap_public_schema(&connection)?;
405
406 // Send heartbeat so daemon knows we're active
407 let _ = daemon::health::send_command(info.health_port, "HEARTBEAT");
408
409 let primary_db_name = path_stem(ephemeral_path);
410 let persistent_was_created = Self::attach_persistent_if_present(
411 &connection,
412 persistent_path.as_deref(),
413 &primary_db_name,
414 )?;
415
416 Ok(Some(Self {
417 hyper: None,
418 daemon_endpoint: Some(info.hyperd_endpoint),
419 daemon_health_port: Some(info.health_port),
420 connection,
421 ephemeral_path: ephemeral_path.to_path_buf(),
422 persistent_path,
423 persistent_was_created,
424 catalog_present_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
425 log_dir: log_dir.to_path_buf(),
426 }))
427 }
428
429 /// Whether the backing `hyperd` is currently reachable.
430 ///
431 /// In local mode, delegates to the owned `HyperProcess`. In daemon mode,
432 /// probes the cached libpq `daemon_endpoint` directly with a short-timeout
433 /// TCP connect — the same endpoint queries run against. This reflects
434 /// *current* liveness of the resource the engine actually depends on, and
435 /// is robust to two failure modes the health-port PING is not:
436 /// - the health port being unreachable (stale `daemon.json`,
437 /// port-scan-adopted daemon, firewall) while the libpq endpoint serves;
438 /// - the daemon restarting `hyperd` on a new port, leaving the cached
439 /// endpoint stale (the probe then correctly reports `false`).
440 ///
441 /// Falls back to discovery (`daemon.json` + health-port PING) only when no
442 /// endpoint has been cached yet (before the first connection attempt).
443 pub fn is_running(&self) -> bool {
444 if let Some(ref hyper) = self.hyper {
445 hyper.is_running()
446 } else if let Some(ref endpoint) = self.daemon_endpoint {
447 probe_endpoint_alive(endpoint)
448 } else {
449 // No cached endpoint yet — fall back to discovery.
450 daemon::discovery::discover().is_some()
451 }
452 }
453
454 /// `host:port` endpoint of the `hyperd` process. Used by the
455 /// watcher to build additional async connections via `hyperdb_api::pool`
456 /// without touching the primary sync connection this engine holds.
457 ///
458 /// # Errors
459 ///
460 /// Returns [`ErrorCode::InternalError`] if the endpoint is unavailable.
461 pub fn hyperd_endpoint(&self) -> Result<String, McpError> {
462 if let Some(ref endpoint) = self.daemon_endpoint {
463 return Ok(endpoint.clone());
464 }
465 self.hyper
466 .as_ref()
467 .ok_or_else(|| McpError::new(ErrorCode::InternalError, "no hyperd endpoint available"))?
468 .require_endpoint()
469 .map(std::string::ToString::to_string)
470 .map_err(|e| McpError::new(ErrorCode::InternalError, e.to_string()))
471 }
472
473 /// The daemon's health port, if this engine is connected via daemon mode.
474 /// Returns `None` in local mode (when this engine owns a private `HyperProcess`).
475 pub fn daemon_health_port(&self) -> Option<u16> {
476 self.daemon_health_port
477 }
478
479 /// Absolute path to the ephemeral primary `.hyper` file on disk.
480 pub fn ephemeral_path(&self) -> &Path {
481 &self.ephemeral_path
482 }
483
484 /// Absolute path to the persistent `.hyper` file, or `None` when the
485 /// session is `--ephemeral-only`.
486 pub fn persistent_path(&self) -> Option<&Path> {
487 self.persistent_path.as_deref()
488 }
489
490 /// Reserved alias under which the persistent database is attached
491 /// when [`Self::persistent_path`] is set. Visible to the LLM via the
492 /// `database` parameter and via `list_attached_databases`.
493 pub const PERSISTENT_ALIAS: &'static str = "persistent";
494
495 /// Unqualified database name Hyper uses for the ephemeral primary —
496 /// the stem of [`Self::ephemeral_path`]. Matches what
497 /// [`hyperdb_api::Connection::new`] registers when it issues its
498 /// implicit `ATTACH DATABASE`, so fully-qualified SQL built with this
499 /// value resolves to the primary.
500 ///
501 /// Also the correct value for `SET schema_search_path = '…'` while
502 /// additional databases are attached: Hyper's default search path
503 /// (`"$single"`) only covers the implicit primary when no other
504 /// databases are attached, and starts resolving unqualified names to
505 /// nothing the moment an `ATTACH DATABASE` runs.
506 pub fn primary_db_name(&self) -> String {
507 self.ephemeral_path
508 .file_stem()
509 .and_then(|s| s.to_str())
510 .unwrap_or("scratch")
511 .to_string()
512 }
513
514 /// Resolve a tool's optional `database` parameter to a concrete
515 /// alias suitable for fully-qualifying SQL. `None` and `Some("")`
516 /// mean "the primary (ephemeral)"; `Some("persistent")` requires the
517 /// persistent attachment exists; any other value is returned
518 /// verbatim and assumed to be a user-attached alias.
519 ///
520 /// Returns the database alias to qualify against, or `None` to mean
521 /// "use the primary's name". This lets callers build qualified SQL
522 /// uniformly: `format!("\"{}\".\"public\".\"{}\"", alias_or_primary, table)`.
523 ///
524 /// # Errors
525 ///
526 /// Returns [`ErrorCode::InvalidArgument`] when `Some("persistent")`
527 /// is passed but [`Self::persistent_path`] is `None`
528 /// (`--ephemeral-only` mode).
529 pub fn resolve_target_db(&self, requested: Option<&str>) -> Result<String, McpError> {
530 match requested.map(str::trim) {
531 None | Some("") => Ok(self.primary_db_name()),
532 Some(other) if other.eq_ignore_ascii_case(Self::PERSISTENT_ALIAS) => {
533 if self.persistent_path.is_none() {
534 return Err(McpError::new(
535 ErrorCode::InvalidArgument,
536 "no persistent database in this session — \
537 hyperdb-mcp was started with --ephemeral-only"
538 .to_string(),
539 ));
540 }
541 // Canonicalize to the lowercase form so SQL identifiers
542 // and attachment registry lookups always agree.
543 Ok(Self::PERSISTENT_ALIAS.to_string())
544 }
545 // Non-persistent aliases are also canonicalized to lowercase
546 // so qualified SQL like `"alias"."public"."t"` matches the
547 // ATTACH form, which `AttachRegistry::attach` lowercases.
548 // Without this, `database="MyDB"` would build qualified SQL
549 // referring to `"MyDB"` while the engine attached as
550 // `"mydb"`, and Hyper (case-sensitive on quoted identifiers)
551 // would reject the lookup.
552 Some(other) => Ok(other.to_ascii_lowercase()),
553 }
554 }
555
556 /// Temporarily redirect the schema search path to `alias` for the
557 /// duration of a tool call. Returns an RAII guard that restores the
558 /// search path to the primary when dropped.
559 ///
560 /// The engine `Mutex` is held by the caller (`with_engine` closure),
561 /// so concurrent tool calls cannot observe the redirected path.
562 ///
563 /// # Errors
564 ///
565 /// Returns [`McpError`] if the SET statement fails (e.g. invalid alias
566 /// or connection lost).
567 pub fn scoped_search_path(&self, alias: &str) -> Result<ScopedSearchPath<'_>, McpError> {
568 let primary = self.primary_db_name();
569 let set_sql = format!("SET schema_search_path = '{}'", alias.replace('\'', "''"));
570 self.execute_command(&set_sql)?;
571 Ok(ScopedSearchPath {
572 engine: self,
573 restore_to: primary,
574 })
575 }
576
577 /// Directory where `hyperd` writes its log files. The MCP binary should
578 /// also drop its own client-side log here so debugging starts in one
579 /// place.
580 pub fn log_dir(&self) -> &Path {
581 &self.log_dir
582 }
583
584 /// Best-guess path to the most recent `hyperd` log file, useful when
585 /// something in the engine misbehaves and we want to surface the server
586 /// log to the caller. Picks the newest `hyperd*.log` file in [`log_dir`].
587 /// Returns `None` if no matching file exists yet.
588 ///
589 /// [`log_dir`]: Self::log_dir
590 pub fn hyperd_log_path(&self) -> Option<PathBuf> {
591 let entries = std::fs::read_dir(&self.log_dir).ok()?;
592 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
593 .filter_map(std::result::Result::ok)
594 .filter_map(|e| {
595 let path = e.path();
596 let name = path.file_name()?.to_str()?;
597 if name.starts_with("hyperd")
598 && std::path::Path::new(name)
599 .extension()
600 .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
601 {
602 let mtime = e.metadata().ok().and_then(|m| m.modified().ok())?;
603 Some((mtime, path))
604 } else {
605 None
606 }
607 })
608 .collect();
609 candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
610 candidates.into_iter().next().map(|(_, p)| p)
611 }
612
613 /// `true` if a persistent database is attached to this session.
614 /// Equivalent to [`Self::persistent_path`] being `Some`.
615 pub fn has_persistent(&self) -> bool {
616 self.persistent_path.is_some()
617 }
618
619 /// `true` when this engine just created the persistent `.hyper` file
620 /// during construction. The server consumes this signal once to
621 /// decide whether to seed `_table_catalog`; subsequent reads stay
622 /// `true` (the flag isn't reset — it's a fact about the engine's
623 /// startup, not a one-shot signal).
624 pub fn persistent_was_just_created(&self) -> bool {
625 self.persistent_was_created
626 }
627
628 /// Returns whether `_table_catalog` exists in `alias`, caching
629 /// the per-DB result on first call so subsequent catalog read/
630 /// write paths skip the `pg_catalog.pg_tables` probe.
631 ///
632 /// `prober` is the SQL-side existence check; the cache layer here
633 /// is intentionally generic so the catalog module can keep its
634 /// probe SQL in one place.
635 ///
636 /// # Errors
637 /// Propagates whatever error `prober` returns on the first call.
638 /// On subsequent calls, the cached value is returned without
639 /// re-running the probe.
640 pub fn catalog_present_in<F>(&self, alias: &str, prober: F) -> Result<bool, McpError>
641 where
642 F: Fn(&Engine) -> Result<bool, McpError>,
643 {
644 let key = alias.to_ascii_lowercase();
645 // Fast path: cache already populated.
646 if let Ok(guard) = self.catalog_present_cache.lock() {
647 if let Some(&present) = guard.get(&key) {
648 return Ok(present);
649 }
650 }
651 // Slow path: run the probe and cache its result.
652 let present = prober(self)?;
653 if let Ok(mut guard) = self.catalog_present_cache.lock() {
654 guard.insert(key, present);
655 }
656 Ok(present)
657 }
658
659 /// Synchronously set the catalog-presence cache to `true` for
660 /// `alias` — used by `table_catalog::ensure_exists_in` after a
661 /// successful `CREATE TABLE IF NOT EXISTS` so subsequent reads/
662 /// writes against that DB skip the existence probe.
663 pub fn mark_catalog_present_for(&self, alias: &str) {
664 let key = alias.to_ascii_lowercase();
665 if let Ok(mut guard) = self.catalog_present_cache.lock() {
666 guard.insert(key, true);
667 }
668 }
669
670 /// Drop the cached probe result for `alias`. Called by
671 /// `detach_database` so that re-attaching the same alias to a
672 /// different file (or with different writability) doesn't reuse a
673 /// stale entry.
674 pub fn clear_catalog_cache_for(&self, alias: &str) {
675 let key = alias.to_ascii_lowercase();
676 if let Ok(mut guard) = self.catalog_present_cache.lock() {
677 guard.remove(&key);
678 }
679 }
680
681 /// Direct access to the underlying connection for operations not
682 /// wrapped by `Engine` (e.g. `export_csv`, `execute_query_to_arrow`).
683 pub fn connection(&self) -> &Connection {
684 &self.connection
685 }
686
687 /// Execute a DDL/DML command. Returns affected row count.
688 ///
689 /// # Errors
690 ///
691 /// Converts any [`hyperdb_api::Error`] from the underlying connection
692 /// into an [`McpError`] — typical causes are SQL syntax errors,
693 /// constraint violations, permission failures, or
694 /// [`ErrorCode::ConnectionLost`] when the link to `hyperd` has
695 /// dropped.
696 pub fn execute_command(&self, sql: &str) -> Result<u64, McpError> {
697 self.connection.execute_command(sql).map_err(McpError::from)
698 }
699
700 /// Run the given closure inside a database transaction.
701 ///
702 /// Issues `BEGIN TRANSACTION` before calling `f`. If `f` returns `Ok`,
703 /// commits the transaction; if it returns `Err`, rolls back and returns
704 /// the original error. A failed rollback is logged via `tracing::warn!`
705 /// and the original error is still surfaced (rollback failure usually
706 /// means the transaction was already aborted by the server, which is
707 /// functionally equivalent to a successful rollback).
708 ///
709 /// This is the correctness primitive for ingest operations: it lets
710 /// per-row `INSERT` loops (Parquet, Arrow, JSON) leave zero partial data
711 /// on failure. The CSV `COPY FROM` path is already atomic at the
712 /// statement level, but wrapping it in a transaction costs nothing and
713 /// makes per-row INSERT loops atomic across the whole batch.
714 ///
715 /// # DDL is auto-committed
716 ///
717 /// Hyper treats `DROP TABLE` and `CREATE TABLE` as auto-committed even
718 /// when issued inside a transaction. This means `replace`-mode ingest
719 /// cannot roll back the original table once DDL has run. The guarantee
720 /// is weaker than it looks: on failure, the new (empty) table stays
721 /// in place rather than being replaced by partial data. Append-mode
722 /// ingest is fully atomic because it doesn't issue DDL on existing
723 /// tables.
724 ///
725 /// # Known wire protocol quirk
726 ///
727 /// After a mid-transaction Hyper-level error (e.g. a NOT NULL violation
728 /// on INSERT), the first SELECT after rollback may return an empty
729 /// result set due to residual bytes on the connection. Retrying the
730 /// query once restores normal behavior. The rollback itself is always
731 /// correct — this is a read-side symptom only. See the `query_resilient`
732 /// helper in `tests/transaction_tests.rs` for a robust pattern.
733 ///
734 /// # Errors
735 ///
736 /// - Returns any [`McpError`] raised by `BEGIN TRANSACTION` or by
737 /// `COMMIT` (typical causes: connection loss, serialization
738 /// conflict, DDL auto-commit contention).
739 /// - Returns whatever error `f` produces (rollback is performed
740 /// first; a rollback failure is only logged, never surfaced).
741 ///
742 /// # Panics
743 ///
744 /// Does not introduce new panic sites. If `f` panics, the transaction
745 /// is rolled back (best-effort) and the original panic is re-raised
746 /// via [`std::panic::resume_unwind`], preserving the panic payload.
747 // The deprecated `begin_transaction`/`commit`/`rollback` raw
748 // methods on `Connection` are required here because this helper
749 // takes `&self` (and so cannot use the RAII guard, which needs
750 // `&mut self`). Migrating requires reshaping `Engine`'s locking
751 // model — see issue #72 for two implementation paths (wrap
752 // connection in a `Mutex` vs. introduce an `EngineTransaction`
753 // guard) and the 8 closure call sites that need updating.
754 #[allow(
755 deprecated,
756 reason = "Engine borrows &self; the RAII guard requires &mut. Migration tracked in issue #72."
757 )]
758 pub fn execute_in_transaction<F, T>(&self, f: F) -> Result<T, McpError>
759 where
760 F: FnOnce(&Engine) -> Result<T, McpError>,
761 {
762 self.connection
763 .begin_transaction()
764 .map_err(McpError::from)?;
765 tracing::debug!("tx: BEGIN issued");
766 // `catch_unwind` wraps the closure so a panic (unwrap on None,
767 // indexing OOB, arithmetic overflow, …) doesn't leave an open
768 // transaction on the connection. Without this, the next tool
769 // call would hit "transaction already in progress" and the
770 // server's ConnectionLost auto-reconnect would *not* recover
771 // because the connection is live; the engine would stay wedged
772 // until restart. `AssertUnwindSafe` is correct here: we hold
773 // the transaction open for the closure's duration, and we
774 // always issue a rollback before resuming the panic, so no
775 // logical invariant survives into the panicking stack.
776 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self)));
777 match result {
778 Ok(Ok(val)) => {
779 tracing::debug!("tx: closure returned Ok, issuing COMMIT");
780 self.connection.commit().map_err(McpError::from)?;
781 Ok(val)
782 }
783 Ok(Err(e)) => {
784 tracing::debug!(err = %e, "tx: closure returned Err, issuing ROLLBACK");
785 if let Err(rb_err) = self.connection.rollback() {
786 // Rollback itself failed — log it but keep the original
787 // error as the primary cause. A failed rollback usually
788 // means the transaction was already aborted by the server,
789 // which is fine (nothing to unwind).
790 tracing::warn!(
791 "rollback after error failed (original error preserved): {}",
792 rb_err
793 );
794 } else {
795 tracing::debug!("tx: ROLLBACK succeeded");
796 }
797 Err(e)
798 }
799 Err(panic_payload) => {
800 tracing::error!("tx: closure panicked, issuing ROLLBACK before resuming unwind");
801 // Best-effort rollback. If it fails, the connection is
802 // unusable — but we're about to panic anyway, and
803 // `HyperMcpServer::with_engine` will drop the engine
804 // when the panic surfaces as a poisoned tokio task.
805 let _ = self.connection.rollback();
806 std::panic::resume_unwind(panic_payload)
807 }
808 }
809 }
810
811 /// Execute a SELECT query and materialize all result rows as a JSON array
812 /// of `{column_name: value}` objects.
813 ///
814 /// Results are consumed chunk-by-chunk to avoid holding the entire result
815 /// set in protocol buffers, though the final `Vec<Value>` does accumulate
816 /// in memory. For truly huge results, prefer `export` to a file instead.
817 ///
818 /// # Errors
819 ///
820 /// Returns any [`McpError`] produced by [`Connection::execute_query`]
821 /// or subsequent `next_chunk` calls — SQL errors, connection loss,
822 /// and decoding failures all surface through this path.
823 pub fn execute_query_to_json(&self, sql: &str) -> Result<Vec<Value>, McpError> {
824 let mut result = self.connection.execute_query(sql).map_err(McpError::from)?;
825
826 let mut rows_json = Vec::new();
827 let mut schema_opt = None;
828 while let Some(chunk) = result.next_chunk().map_err(McpError::from)? {
829 // Capture schema from first chunk
830 if schema_opt.is_none() {
831 schema_opt = result.schema();
832 }
833 if let Some(ref schema) = schema_opt {
834 let columns = schema.columns();
835 for row in &chunk {
836 let mut obj = serde_json::Map::new();
837 for col in columns {
838 let val = row_value_to_json(row, col.index(), &col.sql_type());
839 obj.insert(col.name().to_string(), val);
840 }
841 rows_json.push(Value::Object(obj));
842 }
843 }
844 }
845 Ok(rows_json)
846 }
847
848 /// Create a table from a schema definition.
849 ///
850 /// - `replace = true`: drops the existing table (if any) and recreates it.
851 /// Old rows are lost. Schema is defined by `columns`.
852 /// - `replace = false` (append mode): creates the table only if it doesn't
853 /// already exist. If it does exist, the schema defined here is ignored
854 /// and subsequent inserts must match the existing schema.
855 ///
856 /// Uses `CREATE TABLE IF NOT EXISTS` / `DROP TABLE IF EXISTS` so the
857 /// operation is idempotent without needing a separate `has_table` probe.
858 /// This is important for the watcher path, where a racy `has_table` check
859 /// (false negative due to protocol desync) would otherwise attempt a bare
860 /// `CREATE TABLE` that fails with "42P07 table already exists" and leaves
861 /// the connection in an aborted state.
862 ///
863 /// # Errors
864 ///
865 /// - Returns [`ErrorCode::EmptyData`] if `columns` is empty.
866 /// - Returns [`ErrorCode::SchemaMismatch`] if any column's
867 /// `hyper_type` cannot be resolved by [`crate::schema::map_hyper_type`].
868 /// - Propagates any Hyper error from `DROP TABLE` (when `replace`
869 /// is true) or `CREATE TABLE IF NOT EXISTS`.
870 pub fn create_table(
871 &self,
872 table_name: &str,
873 columns: &[ColumnSchema],
874 replace: bool,
875 ) -> Result<(), McpError> {
876 self.create_table_in(table_name, columns, replace, None)
877 }
878
879 /// Create a table, optionally in a non-primary database. When
880 /// `target_db` is `Some`, the table identifier is fully qualified as
881 /// `"db"."public"."table"`; when `None`, it's just `"table"`.
882 ///
883 /// # Errors
884 ///
885 /// Same as [`Self::create_table`].
886 pub fn create_table_in(
887 &self,
888 table_name: &str,
889 columns: &[ColumnSchema],
890 replace: bool,
891 target_db: Option<&str>,
892 ) -> Result<(), McpError> {
893 if columns.is_empty() {
894 return Err(McpError::new(
895 ErrorCode::EmptyData,
896 "No columns to create table from",
897 ));
898 }
899 for col in columns {
900 if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
901 return Err(McpError::new(
902 ErrorCode::SchemaMismatch,
903 format!(
904 "Unknown type '{}' for column '{}'",
905 col.hyper_type, col.name
906 ),
907 ));
908 }
909 }
910
911 let quoted_table = match target_db {
912 Some(db) => {
913 let esc_db = db.replace('"', "\"\"");
914 let esc_tbl = table_name.replace('"', "\"\"");
915 format!("\"{esc_db}\".\"public\".\"{esc_tbl}\"")
916 }
917 None => format!("\"{}\"", table_name.replace('"', "\"\"")),
918 };
919 if replace {
920 self.connection
921 .execute_command(&format!("DROP TABLE IF EXISTS {quoted_table}"))
922 .map_err(McpError::from)?;
923 }
924
925 let col_defs: Vec<String> = columns
926 .iter()
927 .map(|c| {
928 let nullable = if c.nullable { "" } else { " NOT NULL" };
929 format!(
930 "\"{}\" {}{}",
931 c.name.replace('"', "\"\""),
932 c.hyper_type,
933 nullable
934 )
935 })
936 .collect();
937
938 let create_sql = format!(
939 "CREATE TABLE IF NOT EXISTS {} ({})",
940 quoted_table,
941 col_defs.join(", ")
942 );
943 self.connection
944 .execute_command(&create_sql)
945 .map_err(McpError::from)?;
946 Ok(())
947 }
948
949 /// Returns `(name, hyper_type, nullable)` for every column of `table`,
950 /// in declaration order, by reading the catalog (the same path
951 /// `describe_table` uses). Used by the `merge` ingest path to
952 /// compare incoming-file schema against the existing table.
953 ///
954 /// # Errors
955 ///
956 /// - Propagates [`Catalog::get_table_definition`] errors. Callers
957 /// that need a "table missing" sentinel should pre-check via
958 /// `Catalog::get_table_names("public")` (see `describe_table` for
959 /// the precedent) — `get_table_definition` errors with a
960 /// variable wording across Hyper versions.
961 pub fn column_metadata(&self, table: &str) -> Result<Vec<ColumnSchema>, McpError> {
962 let catalog = Catalog::new(&self.connection);
963 let def = catalog
964 .get_table_definition(table)
965 .map_err(McpError::from)?;
966 Ok(def
967 .columns()
968 .iter()
969 .map(|c| ColumnSchema {
970 name: c.name.clone(),
971 hyper_type: c.type_name().to_string(),
972 nullable: c.nullable,
973 })
974 .collect())
975 }
976
977 /// Like [`Self::column_metadata`] but for a table in `target_db`.
978 /// `None` falls back to `column_metadata` (primary). `Some(alias)`
979 /// reads via the qualified `pg_catalog.pg_attribute` join used by
980 /// `describe_columns_via_pg_catalog` — the connection-bound
981 /// `Catalog` API can't see attached databases.
982 ///
983 /// # Errors
984 ///
985 /// Returns [`ErrorCode::TableNotFound`] when no rows come back from
986 /// the qualified probe. Propagates connection errors.
987 pub fn column_metadata_in(
988 &self,
989 target_db: Option<&str>,
990 table: &str,
991 ) -> Result<Vec<ColumnSchema>, McpError> {
992 let Some(db) = target_db else {
993 return self.column_metadata(table);
994 };
995 let rows = describe_columns_via_pg_catalog(self, db, table)?;
996 if rows.is_empty() {
997 return Err(McpError::new(
998 ErrorCode::TableNotFound,
999 format!("Table '{table}' does not exist in database '{db}'"),
1000 ));
1001 }
1002 Ok(rows
1003 .into_iter()
1004 .map(|r| ColumnSchema {
1005 name: r
1006 .get("name")
1007 .and_then(|v| v.as_str())
1008 .unwrap_or_default()
1009 .to_string(),
1010 hyper_type: r
1011 .get("type")
1012 .and_then(|v| v.as_str())
1013 .unwrap_or_default()
1014 .to_string(),
1015 nullable: r
1016 .get("nullable")
1017 .and_then(serde_json::Value::as_bool)
1018 .unwrap_or(true),
1019 })
1020 .collect())
1021 }
1022
1023 /// Returns true if `table` exists in the `public` schema. Avoids
1024 /// the per-version error-string ambiguity of
1025 /// [`Catalog::get_table_definition`] by listing names instead.
1026 ///
1027 /// # Errors
1028 ///
1029 /// Propagates errors from [`Catalog::get_table_names`] (typically
1030 /// connection loss).
1031 pub fn table_exists(&self, table: &str) -> Result<bool, McpError> {
1032 let catalog = Catalog::new(&self.connection);
1033 let names = catalog.get_table_names("public").map_err(McpError::from)?;
1034 Ok(names.iter().any(|n| n.as_str() == table))
1035 }
1036
1037 /// Like [`Self::table_exists`] but for a table in `target_db`.
1038 /// `None` falls back to `table_exists` (primary). `Some(alias)`
1039 /// probes the qualified `pg_catalog.pg_tables` of the attached
1040 /// database — the connection-bound `Catalog` API can't see
1041 /// attached databases.
1042 ///
1043 /// # Errors
1044 ///
1045 /// Propagates connection errors from the probe query.
1046 pub fn table_exists_in(&self, target_db: Option<&str>, table: &str) -> Result<bool, McpError> {
1047 let Some(db) = target_db else {
1048 return self.table_exists(table);
1049 };
1050 let esc_db = db.replace('"', "\"\"");
1051 let esc_tbl = table.replace('\'', "''");
1052 let sql = format!(
1053 "SELECT 1 AS one FROM \"{esc_db}\".pg_catalog.pg_tables \
1054 WHERE schemaname = 'public' AND tablename = '{esc_tbl}'"
1055 );
1056 let rows = self.execute_query_to_json(&sql)?;
1057 Ok(!rows.is_empty())
1058 }
1059
1060 /// Issue a single `ALTER TABLE "<table>" ADD COLUMN "<n1>" <t1>,
1061 /// ADD COLUMN "<n2>" <t2>, …` statement that adds all columns
1062 /// atomically. Hyper supports the multi-column form (verified
1063 /// 2026-05-07 against the pinned hyperd release), so partial-add
1064 /// failures don't leave the schema half-widened.
1065 ///
1066 /// New columns are always added nullable — existing rows have no
1067 /// value to satisfy NOT NULL. `nullable` on the input is ignored
1068 /// for that reason.
1069 ///
1070 /// `cols` must be non-empty; an empty input is a no-op (returns
1071 /// `Ok(())` without issuing SQL) so callers can pass the
1072 /// "columns missing from target" set directly without a length
1073 /// pre-check.
1074 ///
1075 /// # Errors
1076 ///
1077 /// - Returns [`ErrorCode::SchemaMismatch`] if any element's
1078 /// `hyper_type` is not a known Hyper type (same validation as
1079 /// `create_table`).
1080 /// - Propagates the underlying SQL error from the single ALTER
1081 /// statement. Because Hyper executes a multi-column ADD
1082 /// atomically, a failure leaves the table schema unchanged —
1083 /// no partial widening.
1084 pub fn alter_table_add_columns(
1085 &self,
1086 table: &str,
1087 cols: &[ColumnSchema],
1088 ) -> Result<(), McpError> {
1089 self.alter_table_add_columns_in(None, table, cols)
1090 }
1091
1092 /// Like [`Self::alter_table_add_columns`] but for a table in
1093 /// `target_db`. `None` keeps the unqualified identifier; `Some(alias)`
1094 /// emits `"db"."public"."table"` so the ALTER lands in the attached
1095 /// database.
1096 ///
1097 /// # Errors
1098 ///
1099 /// Same as [`Self::alter_table_add_columns`].
1100 pub fn alter_table_add_columns_in(
1101 &self,
1102 target_db: Option<&str>,
1103 table: &str,
1104 cols: &[ColumnSchema],
1105 ) -> Result<(), McpError> {
1106 if cols.is_empty() {
1107 return Ok(());
1108 }
1109 for col in cols {
1110 if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
1111 return Err(McpError::new(
1112 ErrorCode::SchemaMismatch,
1113 format!(
1114 "Unknown type '{}' for column '{}'",
1115 col.hyper_type, col.name
1116 ),
1117 ));
1118 }
1119 }
1120 let quoted_table = match target_db {
1121 Some(db) => {
1122 let esc_db = db.replace('"', "\"\"");
1123 let esc_tbl = table.replace('"', "\"\"");
1124 format!("\"{esc_db}\".\"public\".\"{esc_tbl}\"")
1125 }
1126 None => format!("\"{}\"", table.replace('"', "\"\"")),
1127 };
1128 let add_clauses = cols
1129 .iter()
1130 .map(|c| {
1131 format!(
1132 "ADD COLUMN \"{}\" {}",
1133 c.name.replace('"', "\"\""),
1134 c.hyper_type
1135 )
1136 })
1137 .collect::<Vec<_>>()
1138 .join(", ");
1139 let sql = format!("ALTER TABLE {quoted_table} {add_clauses}");
1140 self.connection
1141 .execute_command(&sql)
1142 .map_err(McpError::from)?;
1143 Ok(())
1144 }
1145
1146 /// List all tables in the `public` schema with their column definitions
1147 /// and row counts. Returned as a JSON-serializable `Vec` for direct use
1148 /// in MCP tool responses.
1149 ///
1150 /// # Errors
1151 ///
1152 /// - Propagates any error from [`Catalog::get_table_names`] (typically
1153 /// connection loss or SQL errors from the underlying catalog
1154 /// probe).
1155 /// - Propagates any error from `describe_table_with_catalog` for
1156 /// individual tables — a single failing describe aborts the whole
1157 /// listing.
1158 pub fn describe_tables(&self) -> Result<Vec<Value>, McpError> {
1159 let catalog = Catalog::new(&self.connection);
1160 let table_names = catalog.get_table_names("public").map_err(McpError::from)?;
1161 let mut tables = Vec::new();
1162 for name in &table_names {
1163 // Skip infrastructure tables (`_hyperdb_*`) so the public
1164 // catalog only surfaces user-visible data. See
1165 // [`is_internal_table`] for the convention and rationale.
1166 if is_internal_table(name.as_str()) {
1167 continue;
1168 }
1169 tables.push(describe_table_with_catalog(&catalog, name.as_str())?);
1170 }
1171 Ok(tables)
1172 }
1173
1174 /// Describe a single table by name. Returns the same JSON shape as an
1175 /// element of [`Self::describe_tables`] (`name`, `columns`, `row_count`).
1176 ///
1177 /// Errors with [`ErrorCode::TableNotFound`] when the table doesn't exist
1178 /// or is an internal `_hyperdb_*` bookkeeping table (callers should not
1179 /// be able to probe infrastructure via this path; it stays consistent
1180 /// with the full-list variant that hides them).
1181 ///
1182 /// Uses `get_table_names("public")` as the authoritative existence check
1183 /// rather than pattern-matching the error string from
1184 /// `get_table_definition`, because the latter's wording varies across
1185 /// Hyper versions and can slip past `translate_table_missing`.
1186 ///
1187 /// # Errors
1188 ///
1189 /// - Returns [`ErrorCode::TableNotFound`] if `table_name` is an
1190 /// internal `_hyperdb_*` table or does not appear in `public`.
1191 /// - Propagates any error from [`Catalog::get_table_names`] or from
1192 /// `describe_table_with_catalog` (connection loss, catalog probe
1193 /// failures).
1194 pub fn describe_table(&self, table_name: &str) -> Result<Value, McpError> {
1195 if is_internal_table(table_name) {
1196 return Err(McpError::new(
1197 ErrorCode::TableNotFound,
1198 format!("Table '{table_name}' does not exist"),
1199 ));
1200 }
1201 let catalog = Catalog::new(&self.connection);
1202 let exists = catalog
1203 .get_table_names("public")
1204 .map_err(McpError::from)?
1205 .iter()
1206 .any(|n| n.as_str() == table_name);
1207 if !exists {
1208 return Err(McpError::new(
1209 ErrorCode::TableNotFound,
1210 format!("Table '{table_name}' does not exist"),
1211 ));
1212 }
1213 describe_table_with_catalog(&catalog, table_name)
1214 }
1215
1216 /// Sample rows from a table along with its schema and total row count.
1217 ///
1218 /// Returns a single JSON object with `table`, `row_count`, `sample_size`,
1219 /// `schema`, and `rows`. `n` is clamped to the range `1..=100`.
1220 /// Returns [`ErrorCode::TableNotFound`] if the table doesn't exist.
1221 ///
1222 /// Avoids the `Catalog::has_table` probe entirely — we just run the sample
1223 /// SELECT first and translate a Hyper "table does not exist" error into
1224 /// our own [`ErrorCode::TableNotFound`]. This sidesteps the old pattern
1225 /// where a racy `has_table` silently returning `Err` would be rewritten
1226 /// to `false` and surface as a spurious `TableNotFound` for tables that
1227 /// actually exist.
1228 ///
1229 /// # Errors
1230 ///
1231 /// - Returns [`ErrorCode::TableNotFound`] (via `translate_table_missing`)
1232 /// if the sample `SELECT` surfaces a Hyper "table does not exist" error.
1233 /// - Propagates any other [`McpError`] from the sample query — SQL
1234 /// errors, permission failures, or connection loss.
1235 /// - The subsequent `COUNT(*)` and `get_table_definition` calls are
1236 /// best-effort: their errors are swallowed so the sample payload
1237 /// is still returned when available.
1238 pub fn sample_table(&self, table_name: &str, n: u64) -> Result<Value, McpError> {
1239 self.sample_table_in(None, table_name, n)
1240 }
1241
1242 /// Sample rows from a table in `target_db` (or the primary when `None`).
1243 ///
1244 /// # Errors
1245 ///
1246 /// Same as [`Self::sample_table`].
1247 pub fn sample_table_in(
1248 &self,
1249 target_db: Option<&str>,
1250 table_name: &str,
1251 n: u64,
1252 ) -> Result<Value, McpError> {
1253 let n = n.clamp(1, 100);
1254 let qualified = match target_db {
1255 Some(db) => {
1256 let esc_db = db.replace('"', "\"\"");
1257 let esc_tbl = table_name.replace('"', "\"\"");
1258 format!("\"{esc_db}\".\"public\".\"{esc_tbl}\"")
1259 }
1260 None => format!("\"{}\"", table_name.replace('"', "\"\"")),
1261 };
1262
1263 let select_sql = format!("SELECT * FROM {qualified} LIMIT {n}");
1264 let rows = match self.execute_query_to_json(&select_sql) {
1265 Ok(r) => r,
1266 Err(e) => return Err(translate_table_missing(e, table_name)),
1267 };
1268
1269 let count_sql = format!("SELECT COUNT(*) AS cnt FROM {qualified}");
1270 let row_count = self
1271 .execute_query_to_json(&count_sql)
1272 .ok()
1273 .and_then(|rs| {
1274 rs.first()
1275 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
1276 })
1277 .unwrap_or(0);
1278
1279 // Column metadata: when targeting the primary, use the
1280 // connection-bound Catalog. For other databases, query
1281 // pg_catalog.pg_attribute directly via fully-qualified SQL.
1282 let columns: Vec<Value> = match target_db {
1283 None => {
1284 let catalog = Catalog::new(&self.connection);
1285 catalog
1286 .get_table_definition(table_name)
1287 .map(|def| {
1288 def.columns()
1289 .iter()
1290 .map(|col| {
1291 json!({
1292 "name": col.name,
1293 "type": col.type_name(),
1294 "nullable": col.nullable,
1295 })
1296 })
1297 .collect()
1298 })
1299 .unwrap_or_default()
1300 }
1301 Some(db) => describe_columns_via_pg_catalog(self, db, table_name).unwrap_or_default(),
1302 };
1303
1304 Ok(json!({
1305 "table": table_name,
1306 "row_count": row_count,
1307 "sample_size": rows.len(),
1308 "schema": columns,
1309 "rows": rows,
1310 }))
1311 }
1312
1313 /// List public tables in `target_db` (or the primary when `None`).
1314 ///
1315 /// # Errors
1316 ///
1317 /// Returns [`McpError`] on catalog query failure.
1318 pub fn describe_tables_in(&self, target_db: Option<&str>) -> Result<Vec<Value>, McpError> {
1319 match target_db {
1320 None => self.describe_tables(),
1321 Some(db) => {
1322 let esc_db = db.replace('"', "\"\"");
1323 let list_sql = format!(
1324 "SELECT tablename FROM \"{esc_db}\".pg_catalog.pg_tables \
1325 WHERE schemaname = 'public' ORDER BY tablename"
1326 );
1327 let names_rows = self.execute_query_to_json(&list_sql)?;
1328 let mut out = Vec::new();
1329 for row in &names_rows {
1330 let Some(name) = row.get("tablename").and_then(|v| v.as_str()) else {
1331 continue;
1332 };
1333 if is_internal_table(name) {
1334 continue;
1335 }
1336 out.push(self.describe_table_in(Some(db), name)?);
1337 }
1338 Ok(out)
1339 }
1340 }
1341 }
1342
1343 /// Describe a single table in `target_db` (or the primary when `None`).
1344 ///
1345 /// # Errors
1346 ///
1347 /// Same as [`Self::describe_table`].
1348 pub fn describe_table_in(
1349 &self,
1350 target_db: Option<&str>,
1351 table_name: &str,
1352 ) -> Result<Value, McpError> {
1353 if is_internal_table(table_name) {
1354 return Err(McpError::new(
1355 ErrorCode::TableNotFound,
1356 format!("Table '{table_name}' does not exist"),
1357 ));
1358 }
1359 match target_db {
1360 None => self.describe_table(table_name),
1361 Some(db) => {
1362 // Existence check via pg_catalog
1363 let esc_db = db.replace('"', "\"\"");
1364 let esc_tbl = table_name.replace('\'', "''");
1365 let exists_sql = format!(
1366 "SELECT 1 FROM \"{esc_db}\".pg_catalog.pg_tables \
1367 WHERE schemaname = 'public' AND tablename = '{esc_tbl}'"
1368 );
1369 let rows = self.execute_query_to_json(&exists_sql)?;
1370 if rows.is_empty() {
1371 return Err(McpError::new(
1372 ErrorCode::TableNotFound,
1373 format!("Table '{table_name}' does not exist in database '{db}'"),
1374 ));
1375 }
1376 // Columns via pg_catalog.pg_attribute
1377 let columns = describe_columns_via_pg_catalog(self, db, table_name)?;
1378 // Row count
1379 let qualified = format!(
1380 "\"{esc_db}\".\"public\".\"{}\"",
1381 table_name.replace('"', "\"\"")
1382 );
1383 let count_sql = format!("SELECT COUNT(*) AS cnt FROM {qualified}");
1384 let row_count = self
1385 .execute_query_to_json(&count_sql)
1386 .ok()
1387 .and_then(|rs| {
1388 rs.first()
1389 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
1390 })
1391 .unwrap_or(0);
1392 Ok(json!({
1393 "name": table_name,
1394 "row_count": row_count,
1395 "columns": columns,
1396 }))
1397 }
1398 }
1399 }
1400
1401 /// Collect workspace health and size metrics for the `status` MCP tool.
1402 ///
1403 /// Includes `logs` with paths to the `hyperd` log file (if one exists yet)
1404 /// and the MCP client log. These are the first files to check when
1405 /// something misbehaves.
1406 ///
1407 /// # Errors
1408 ///
1409 /// Propagates any error from [`Catalog::get_table_names`]. Per-table
1410 /// row counts and disk usage fall back to `0` on read failure, so
1411 /// these do not bubble up.
1412 pub fn status(&self) -> Result<Value, McpError> {
1413 let catalog = Catalog::new(&self.connection);
1414 let all_names = catalog.get_table_names("public").map_err(McpError::from)?;
1415 // Same filter as `describe_tables`: the saved-queries meta-table
1416 // and any other `_hyperdb_*` internal tables shouldn't bump the
1417 // user-visible `table_count` / `total_rows`.
1418 let table_names: Vec<_> = all_names
1419 .iter()
1420 .filter(|n| !is_internal_table(n.as_str()))
1421 .collect();
1422 let table_count = table_names.len();
1423
1424 let total_rows: i64 = table_names
1425 .iter()
1426 .map(|name| catalog.get_row_count(name.as_str()).unwrap_or(0))
1427 .sum();
1428
1429 // Disk size of the ephemeral primary. The persistent file is
1430 // reported separately when present.
1431 let ephemeral_bytes = std::fs::metadata(&self.ephemeral_path).map_or(0, |m| m.len());
1432 let persistent_bytes = self
1433 .persistent_path
1434 .as_ref()
1435 .and_then(|p| std::fs::metadata(p).ok())
1436 .map_or(0u64, |m| m.len());
1437 let disk_bytes = ephemeral_bytes.saturating_add(persistent_bytes);
1438
1439 let hyperd_log = self.hyperd_log_path().map_or(Value::Null, |p| {
1440 Value::String(p.to_string_lossy().into_owned())
1441 });
1442 let client_log_path = self.log_dir.join(CLIENT_LOG_FILE_NAME);
1443 let client_log = if client_log_path.exists() {
1444 Value::String(client_log_path.to_string_lossy().into_owned())
1445 } else {
1446 Value::Null
1447 };
1448
1449 let persistent_path_value = self.persistent_path.as_ref().map_or(Value::Null, |p| {
1450 Value::String(p.to_string_lossy().into_owned())
1451 });
1452
1453 // Connection details for the backing `hyperd`. In daemon mode the
1454 // endpoint and health port come from the shared daemon's discovery
1455 // file; in local mode (`--no-daemon`) this engine owns a private
1456 // `hyperd` and there is no health port. `hyperd_endpoint()` only errors
1457 // if no endpoint is available at all, which `is_running` already
1458 // reflects — surface it as null rather than failing the whole status.
1459 let in_daemon_mode = self.daemon_endpoint.is_some();
1460 let endpoint_value = self.hyperd_endpoint().map_or(Value::Null, Value::String);
1461 let health_port_value = self
1462 .daemon_health_port
1463 .map_or(Value::Null, |p| Value::Number(p.into()));
1464
1465 Ok(json!({
1466 "hyperd_running": self.is_running(),
1467 "ephemeral_path": self.ephemeral_path.to_string_lossy(),
1468 "persistent_path": persistent_path_value,
1469 "has_persistent": self.has_persistent(),
1470 "table_count": table_count,
1471 "total_rows": total_rows,
1472 "disk_usage_bytes": disk_bytes,
1473 // Where this engine is talking to hyperd. `hyperd_endpoint` is the
1474 // libpq endpoint queries run against; `daemon_health_port` is the
1475 // shared daemon's control/lock port (null in local mode).
1476 "engine": {
1477 "mode": if in_daemon_mode { "daemon" } else { "local" },
1478 "hyperd_endpoint": endpoint_value,
1479 "daemon_health_port": health_port_value,
1480 },
1481 // The MCP server and the `hyperdb-api` crate it's built on live in
1482 // the same Cargo workspace and ship from the same commit, so a
1483 // single version string identifies both. Label it by the
1484 // underlying library since that's the more fundamental
1485 // identifier — the MCP server is a thin layer over the Hyper
1486 // Rust API.
1487 "hyper_rust_api_version": crate::version::hyper_api_version_string(),
1488 "logs": {
1489 "log_dir": self.log_dir.to_string_lossy(),
1490 "hyperd_log": hyperd_log,
1491 "client_log": client_log,
1492 },
1493 }))
1494 }
1495}
1496
1497/// Convert a single cell from a Hyper result row into a JSON `Value`.
1498///
1499/// Dispatches on the column's SQL OID so each type is decoded through the
1500/// right [`hyperdb_api::Row::get`] instantiation. When a type isn't explicitly
1501/// handled, falls back to string decoding — safe for textual types but
1502/// produces garbage for binary types, so every type we might actually see
1503/// should have its own branch.
1504///
1505/// # Type mapping
1506///
1507/// | Hyper OID | JSON shape |
1508/// |-----------|------------|
1509/// | `BOOL` | `true`/`false` |
1510/// | `SMALL_INT` / `INT` / `BIG_INT` | number |
1511/// | `DOUBLE` / `FLOAT` | number |
1512/// | `NUMERIC` | number when losslessly representable as `f64`, else string |
1513/// | `DATE` | ISO 8601 date string (`YYYY-MM-DD`) |
1514/// | `TIMESTAMP` / `TIMESTAMP_TZ` | ISO 8601 timestamp string |
1515/// | `TEXT` / `VARCHAR` | string |
1516/// | anything else | string (fallback; may be garbage for binary types) |
1517fn row_value_to_json(row: &hyperdb_api::Row, idx: usize, sql_type: &SqlType) -> Value {
1518 use hyperdb_api::oids;
1519 use hyperdb_api::{Date, Numeric, OffsetTimestamp, Timestamp};
1520
1521 if row.is_null(idx) {
1522 return Value::Null;
1523 }
1524 let oid_val = sql_type.internal_oid();
1525 if oid_val == oids::BOOL.0 {
1526 return row.get::<bool>(idx).map_or(Value::Null, Value::Bool);
1527 }
1528 if oid_val == oids::SMALL_INT.0 {
1529 return row
1530 .get::<i16>(idx)
1531 .map_or(Value::Null, |v| Value::Number(v.into()));
1532 }
1533 if oid_val == oids::INT.0 {
1534 return row
1535 .get::<i32>(idx)
1536 .map_or(Value::Null, |v| Value::Number(v.into()));
1537 }
1538 if oid_val == oids::BIG_INT.0 {
1539 return row
1540 .get::<i64>(idx)
1541 .map_or(Value::Null, |v| Value::Number(v.into()));
1542 }
1543 if oid_val == oids::DOUBLE.0 || oid_val == oids::FLOAT.0 {
1544 return row
1545 .get::<f64>(idx)
1546 .and_then(|v| serde_json::Number::from_f64(v).map(Value::Number))
1547 .unwrap_or(Value::Null);
1548 }
1549 if oid_val == oids::NUMERIC.0 {
1550 // `Row` is schema-aware as of the upstream NUMERIC fix — it
1551 // carries an `Arc<ResultSchema>` and `row.get::<Numeric>()`
1552 // reads the scale from the column's
1553 // `SqlType::Numeric { precision, scale }` descriptor before
1554 // dispatching on the buffer length. That covers all three
1555 // NUMERIC wire shapes the server can send on a query result:
1556 //
1557 // * 8-byte `Numeric` (precision ≤ 18, e.g. `AVG(INT)`)
1558 // * 16-byte `BigNumeric` (precision > 18)
1559 // * Arrow `Decimal128`/`Decimal256` (gRPC transport)
1560 //
1561 // Prior to the upstream fix, `type_modifier` was being dropped
1562 // during `RowDescription` parsing so the scale presented here
1563 // was always `0`, the 8-byte form wasn't decodable at all, and
1564 // `AVG` results fell through to `Null`. All of that is now
1565 // handled inside `hyperdb-api`; this function only needs to pick
1566 // the JSON shape.
1567 //
1568 // `Numeric::to_string()` uses the decoded scale, so round-trip
1569 // through `f64` is only used for JSON compactness — if the
1570 // value doesn't fit in `f64` losslessly (`serde_json::Number::
1571 // from_f64` returns `None` for NaN/Infinity, and we can't
1572 // always represent large i128 exactly as `f64`), fall back to
1573 // the string form so the caller sees the exact value.
1574 return row.get::<Numeric>(idx).map_or(Value::Null, |n| {
1575 let s = n.to_string();
1576 s.parse::<f64>()
1577 .ok()
1578 .and_then(serde_json::Number::from_f64)
1579 .map(Value::Number)
1580 .unwrap_or(Value::String(s))
1581 });
1582 }
1583 if oid_val == oids::DATE.0 {
1584 // `Date`'s `Display` impl already formats as ISO 8601 `YYYY-MM-DD`.
1585 return row
1586 .get::<Date>(idx)
1587 .map_or(Value::Null, |d| Value::String(d.to_string()));
1588 }
1589 if oid_val == oids::TIMESTAMP.0 {
1590 return row
1591 .get::<Timestamp>(idx)
1592 .map_or(Value::Null, |t| Value::String(t.to_string()));
1593 }
1594 if oid_val == oids::TIMESTAMP_TZ.0 {
1595 return row
1596 .get::<OffsetTimestamp>(idx)
1597 .map_or(Value::Null, |t| Value::String(t.to_string()));
1598 }
1599 if oid_val == oids::TEXT.0 || oid_val == oids::VARCHAR.0 {
1600 return row.get::<String>(idx).map_or(Value::Null, Value::String);
1601 }
1602 // Fallback: try as string. Safe for textual types we didn't list;
1603 // produces garbage bytes for binary types (BYTEA, GEOGRAPHY, …)
1604 // — add explicit branches above when those start appearing in
1605 // real queries.
1606 row.get::<String>(idx).map_or(Value::Null, Value::String)
1607}
1608
1609/// Name of the client-side log file written in [`resolve_log_dir`].
1610/// The MCP binary's `main` opens this file and sets it as a `tracing`
1611/// subscriber target so both startup errors and runtime events land here.
1612pub const CLIENT_LOG_FILE_NAME: &str = "hyperdb-mcp.log";
1613
1614/// Name-prefix convention for tables that belong to the `HyperDB` MCP's
1615/// own infrastructure (currently the `_hyperdb_saved_queries` meta-table
1616/// used by `WorkspaceStore`). Hidden from [`Engine::describe_tables`]
1617/// and from [`Engine::status`]'s `table_count` / `total_rows`, so users
1618/// never see `HyperDB`'s own bookkeeping in the public catalog.
1619///
1620/// Any future internal table (watcher state, audit log, etc.) just
1621/// needs to follow this prefix and it disappears from the public view
1622/// automatically — no per-table filter list to keep in sync.
1623pub const HYPERDB_INTERNAL_PREFIX: &str = "_hyperdb_";
1624
1625/// Returns true when `name` is one of `HyperDB`'s own internal tables
1626/// (matches [`HYPERDB_INTERNAL_PREFIX`]). Factored into a helper so
1627/// every filter site calls the same predicate and a future move to a
1628/// more nuanced scheme (e.g. per-table allowlist) is a single edit.
1629///
1630/// Note: `_table_catalog` lives in the persistent attachment, not the
1631/// ephemeral primary, so it doesn't show up in `describe_tables` even
1632/// without the filter — `describe_tables` only enumerates the primary.
1633#[must_use]
1634pub fn is_internal_table(name: &str) -> bool {
1635 name.starts_with(HYPERDB_INTERNAL_PREFIX)
1636}
1637
1638/// Compute the log directory for both `hyperd` output and the client-side
1639/// tracing log. Shared by [`Engine::new`] and `main` so both land in the
1640/// same place.
1641///
1642/// - When a persistent path is supplied: same directory as that file
1643/// (with `~` expansion applied). A project DB like
1644/// `~/projects/foo.hyper` gets logs in `~/projects/`.
1645/// - When no persistent path is supplied (ephemeral-only sessions):
1646/// `$TMPDIR/hyperdb-mcp-<pid>/`. Multiple engines in the same PID
1647/// share this log dir, which is fine — `tracing` is process-wide and
1648/// the `.hyper` files themselves live in distinct per-engine subdirs.
1649#[must_use]
1650pub fn resolve_log_dir(persistent_db_path: Option<&str>) -> PathBuf {
1651 match persistent_db_path {
1652 Some(p) => {
1653 let expanded = PathBuf::from(shellexpand_tilde(p));
1654 expanded
1655 .parent()
1656 .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf)
1657 }
1658 None => std::env::temp_dir().join(format!("hyperdb-mcp-{}", std::process::id())),
1659 }
1660}
1661
1662/// Build the `{name, columns, row_count}` JSON for a single table, shared
1663/// between [`Engine::describe_tables`] (bulk) and [`Engine::describe_table`]
1664/// (single) so both paths emit byte-identical shapes. A missing table
1665/// surfaces as the underlying Hyper "relation does not exist" error; single-
1666/// table callers should run it through `translate_table_missing`.
1667/// Describe columns of `table_name` in attached database `db_alias` by
1668/// querying that database's `pg_catalog.pg_attribute` directly. Used when
1669/// the connection-bound `Catalog` API can't see the target database.
1670fn describe_columns_via_pg_catalog(
1671 engine: &Engine,
1672 db_alias: &str,
1673 table_name: &str,
1674) -> Result<Vec<Value>, McpError> {
1675 let esc_db = db_alias.replace('"', "\"\"");
1676 let esc_tbl = table_name.replace('\'', "''");
1677 let sql = format!(
1678 "SELECT a.attname AS name, \
1679 t.typname AS type_name, \
1680 NOT a.attnotnull AS nullable, \
1681 a.attnum AS ordinal \
1682 FROM \"{esc_db}\".pg_catalog.pg_attribute a \
1683 JOIN \"{esc_db}\".pg_catalog.pg_class c ON a.attrelid = c.oid \
1684 JOIN \"{esc_db}\".pg_catalog.pg_namespace n ON c.relnamespace = n.oid \
1685 JOIN \"{esc_db}\".pg_catalog.pg_type t ON a.atttypid = t.oid \
1686 WHERE n.nspname = 'public' \
1687 AND c.relname = '{esc_tbl}' \
1688 AND a.attnum > 0 \
1689 ORDER BY a.attnum"
1690 );
1691 let rows = engine.execute_query_to_json(&sql)?;
1692 Ok(rows
1693 .into_iter()
1694 .map(|r| {
1695 json!({
1696 "name": r.get("name").cloned().unwrap_or(Value::Null),
1697 "type": r.get("type_name").cloned().unwrap_or(Value::Null),
1698 "nullable": r.get("nullable").cloned().unwrap_or(Value::Bool(true)),
1699 })
1700 })
1701 .collect())
1702}
1703
1704fn describe_table_with_catalog(catalog: &Catalog<'_>, name: &str) -> Result<Value, McpError> {
1705 let def = catalog.get_table_definition(name).map_err(McpError::from)?;
1706 let row_count = catalog.get_row_count(name).unwrap_or(0);
1707 let columns: Vec<Value> = def
1708 .columns()
1709 .iter()
1710 .map(|col| {
1711 json!({
1712 "name": col.name,
1713 "type": col.type_name(),
1714 "nullable": col.nullable,
1715 })
1716 })
1717 .collect();
1718 Ok(json!({
1719 "name": name,
1720 "columns": columns,
1721 "row_count": row_count,
1722 }))
1723}
1724
1725/// Translate an "undefined table / relation does not exist" error from Hyper
1726/// into our own [`ErrorCode::TableNotFound`] with a consistent message.
1727/// Any other error is passed through unchanged.
1728fn translate_table_missing(err: McpError, table_name: &str) -> McpError {
1729 let m = err.message.to_lowercase();
1730 let looks_like_missing = m.contains("does not exist")
1731 || m.contains("relation")
1732 || m.contains("undefined table")
1733 || err.message.contains("42P01");
1734 if looks_like_missing {
1735 McpError::new(
1736 ErrorCode::TableNotFound,
1737 format!("Table '{table_name}' does not exist"),
1738 )
1739 } else {
1740 err
1741 }
1742}
1743
1744/// Returns `true` if a SQL statement is read-only: `SELECT`, `WITH`, `EXPLAIN`,
1745/// `SHOW`, or `VALUES`. Anything else (`CREATE`, `INSERT`, `UPDATE`, `DELETE`,
1746/// `DROP`, `ALTER`, `COPY`, ...) is considered mutating.
1747///
1748/// The check is a simple prefix match after trimming and upper-casing the first
1749/// Checks whether the first SQL keyword indicates a read-only statement.
1750///
1751/// Strips leading whitespace and SQL comments (line `--` and block `/* */`)
1752/// before inspecting the first alphabetic token. This prevents comment-based
1753/// bypass of the read-only guard (e.g. `/* harmless */ DROP TABLE ...`).
1754///
1755/// Note: data-modifying CTEs (`WITH x AS (DELETE ...) SELECT ...`) still slip
1756/// past this check. Hyper itself rejects such CTEs, so this is defense-in-depth
1757/// rather than the sole security boundary.
1758#[must_use]
1759pub fn is_read_only_sql(sql: &str) -> bool {
1760 matches!(classify_statement(sql), StatementKind::ReadOnly)
1761}
1762
1763/// Coarse classification of a single SQL statement, comment-aware.
1764///
1765/// Used by the atomic-batch `execute` tool to enforce the rule "a batch
1766/// must be either all-DDL singletons or all-DML; mixing the two aborts
1767/// the transaction with SQLSTATE 0A000". The first-keyword heuristic
1768/// matches what `is_read_only_sql` already trusts elsewhere in the
1769/// codebase.
1770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1771pub enum StatementKind {
1772 /// `SELECT` / `WITH` / `EXPLAIN` / `SHOW` / `VALUES`.
1773 ReadOnly,
1774 /// `CREATE` / `DROP` / `ALTER` / `TRUNCATE` / `RENAME` — Hyper auto-commits.
1775 Ddl,
1776 /// `INSERT` / `UPDATE` / `DELETE` / `COPY` / `MERGE` — transactional.
1777 Dml,
1778 /// `BEGIN` / `START` / `COMMIT` / `END` / `ROLLBACK` / `ABORT` /
1779 /// `SAVEPOINT` / `RELEASE`. Rejected inside a batch because the
1780 /// `execute` tool already manages the transaction; an explicit
1781 /// COMMIT mid-batch would defeat atomicity.
1782 TransactionControl,
1783 /// Empty/comment-only input or an unrecognized first keyword. Treated
1784 /// as opaque by the batch validator (passed through to Hyper).
1785 Other,
1786}
1787
1788/// Coarse-classify the first SQL statement in `sql` after stripping
1789/// leading whitespace and line/block comments.
1790///
1791/// First-keyword only: a `WITH x AS (DELETE …) SELECT …` CTE is
1792/// classified as `ReadOnly` even though it mutates. Hyper itself
1793/// rejects data-modifying CTEs, so this is a defense-in-depth heuristic
1794/// rather than the only barrier.
1795#[must_use]
1796pub fn classify_statement(sql: &str) -> StatementKind {
1797 let stripped = strip_leading_sql_comments(sql);
1798 let first_token: String = stripped
1799 .chars()
1800 .take_while(|c| c.is_alphabetic())
1801 .flat_map(char::to_uppercase)
1802 .collect();
1803 match first_token.as_str() {
1804 "SELECT" | "WITH" | "EXPLAIN" | "SHOW" | "VALUES" => StatementKind::ReadOnly,
1805 "CREATE" | "DROP" | "ALTER" | "TRUNCATE" | "RENAME" => StatementKind::Ddl,
1806 "INSERT" | "UPDATE" | "DELETE" | "COPY" | "MERGE" => StatementKind::Dml,
1807 "BEGIN" | "START" | "COMMIT" | "END" | "ROLLBACK" | "ABORT" | "SAVEPOINT" | "RELEASE" => {
1808 StatementKind::TransactionControl
1809 }
1810 _ => StatementKind::Other,
1811 }
1812}
1813
1814/// Strips leading whitespace, line comments (`--`), and block comments (`/* */`)
1815/// from SQL text. Handles nested block comments.
1816pub(crate) fn strip_leading_sql_comments(sql: &str) -> &str {
1817 let mut s = sql;
1818 loop {
1819 s = s.trim_start();
1820 if s.starts_with("--") {
1821 // Line comment — skip to end of line (handles LF, CRLF, and CR)
1822 match s.find(&['\n', '\r'][..]) {
1823 Some(pos) => {
1824 let mut next = pos + 1;
1825 // Handle CRLF: skip both characters
1826 if s.as_bytes().get(pos) == Some(&b'\r')
1827 && s.as_bytes().get(pos + 1) == Some(&b'\n')
1828 {
1829 next = pos + 2;
1830 }
1831 s = &s[next..];
1832 }
1833 None => return "",
1834 }
1835 } else if s.starts_with("/*") {
1836 // Block comment — find matching close, handling nesting
1837 let mut depth = 0u32;
1838 let mut chars = s.char_indices().peekable();
1839 let mut end = None;
1840 while let Some((i, c)) = chars.next() {
1841 if c == '/' && chars.peek().map(|(_, c2)| *c2) == Some('*') {
1842 chars.next();
1843 depth += 1;
1844 } else if c == '*' && chars.peek().map(|(_, c2)| *c2) == Some('/') {
1845 chars.next();
1846 depth -= 1;
1847 if depth == 0 {
1848 end = Some(i + 2);
1849 break;
1850 }
1851 }
1852 }
1853 match end {
1854 Some(pos) => s = &s[pos..],
1855 None => return "", // Unclosed comment — no valid SQL
1856 }
1857 } else {
1858 break;
1859 }
1860 }
1861 s
1862}
1863
1864impl Drop for Engine {
1865 fn drop(&mut self) {
1866 // The ephemeral primary is always cleaned up. In daemon mode the
1867 // shared hyperd holds the file handle even after this engine is
1868 // dropped, so we DETACH first (Windows enforces file locks; this
1869 // is a no-op on Unix but keeps behavior identical across platforms).
1870 // The persistent attachment is left in place — its lifetime
1871 // outlives the engine.
1872 if self.daemon_endpoint.is_some() {
1873 let db_name = self.primary_db_name();
1874 let detach = format!("DETACH DATABASE \"{db_name}\"");
1875 let _ = self.connection.execute_command(&detach);
1876 }
1877 // Remove the per-pid temp directory holding the ephemeral file.
1878 // Safe in both daemon and local modes: in local mode the
1879 // HyperProcess Drop tears down hyperd before this fires (Drop
1880 // runs in field-declaration order), so the file is no longer
1881 // open by the time we delete it.
1882 if let Some(parent) = self.ephemeral_path.parent() {
1883 let _ = std::fs::remove_dir_all(parent);
1884 }
1885 }
1886}
1887
1888/// Cheap liveness probe for a daemon-mode `hyperd`: attempt a short-timeout
1889/// TCP connect to `endpoint` (`host:port`). Returns `true` if the connect
1890/// succeeds (something is listening). A bare connect is sufficient here — we
1891/// only need to know the port the engine's libpq connection targets is still
1892/// accepting connections, not to perform a full protocol round-trip.
1893fn probe_endpoint_alive(endpoint: &str) -> bool {
1894 use std::net::ToSocketAddrs;
1895 const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300);
1896 match endpoint.to_socket_addrs() {
1897 // Probe each resolved address, short-circuiting on the first that
1898 // accepts a connection. `daemon_endpoint` is normally a single
1899 // `127.0.0.1:PORT`, so this is one connect in the common case.
1900 Ok(mut addrs) => {
1901 addrs.any(|addr| std::net::TcpStream::connect_timeout(&addr, PROBE_TIMEOUT).is_ok())
1902 }
1903 Err(_) => false,
1904 }
1905}
1906
1907fn bootstrap_public_schema(connection: &Connection) -> Result<(), McpError> {
1908 connection
1909 .execute_command("CREATE SCHEMA IF NOT EXISTS public")
1910 .map(|_| ())
1911 .map_err(|e| {
1912 McpError::new(
1913 ErrorCode::InternalError,
1914 format!("Failed to bootstrap public schema: {e}"),
1915 )
1916 })
1917}
1918
1919/// Minimal `~/` (and `~\` on Windows) expansion. Resolves the home
1920/// directory via `$HOME` on Unix and `%USERPROFILE%` (falling back to
1921/// `%HOMEDRIVE%%HOMEPATH%`) on Windows. `~username/` is not supported —
1922/// callers who need that should expand their paths themselves.
1923fn shellexpand_tilde(path: &str) -> String {
1924 let rest = if let Some(r) = path.strip_prefix("~/") {
1925 Some(r)
1926 } else if cfg!(windows) {
1927 path.strip_prefix("~\\")
1928 } else {
1929 None
1930 };
1931 let Some(rest) = rest else {
1932 return path.to_string();
1933 };
1934 let Some(home) = home_dir() else {
1935 return path.to_string();
1936 };
1937 let sep = std::path::MAIN_SEPARATOR;
1938 format!("{}{sep}{rest}", home.to_string_lossy())
1939}
1940
1941/// Resolve the user's home directory across platforms. Unix uses `$HOME`;
1942/// Windows prefers `%USERPROFILE%` and falls back to `%HOMEDRIVE%%HOMEPATH%`.
1943fn home_dir() -> Option<PathBuf> {
1944 if cfg!(windows) {
1945 if let Some(profile) = std::env::var_os("USERPROFILE") {
1946 if !profile.is_empty() {
1947 return Some(PathBuf::from(profile));
1948 }
1949 }
1950 let drive = std::env::var_os("HOMEDRIVE")?;
1951 let rel = std::env::var_os("HOMEPATH")?;
1952 let mut combined = PathBuf::from(drive);
1953 combined.push(PathBuf::from(rel));
1954 Some(combined)
1955 } else {
1956 std::env::var_os("HOME").map(PathBuf::from)
1957 }
1958}
1959
1960#[cfg(test)]
1961mod statement_helper_tests {
1962 use super::*;
1963
1964 #[test]
1965 fn classify_statement_recognizes_each_kind() {
1966 assert_eq!(classify_statement("SELECT 1"), StatementKind::ReadOnly);
1967 assert_eq!(
1968 classify_statement("with x as (..) select * from x"),
1969 StatementKind::ReadOnly
1970 );
1971 assert_eq!(
1972 classify_statement("CREATE TABLE t (i INT)"),
1973 StatementKind::Ddl
1974 );
1975 assert_eq!(classify_statement("drop table t"), StatementKind::Ddl);
1976 assert_eq!(
1977 classify_statement("INSERT INTO t VALUES (1)"),
1978 StatementKind::Dml
1979 );
1980 assert_eq!(classify_statement("update t set i = 2"), StatementKind::Dml);
1981 assert_eq!(classify_statement("delete from t"), StatementKind::Dml);
1982 assert_eq!(classify_statement(""), StatementKind::Other);
1983 }
1984
1985 #[test]
1986 fn classify_statement_recognizes_transaction_control() {
1987 for kw in [
1988 "BEGIN",
1989 "Begin transaction",
1990 "START TRANSACTION",
1991 "COMMIT",
1992 "Commit work",
1993 "END",
1994 "ROLLBACK",
1995 "Rollback to savepoint sp1",
1996 "ABORT",
1997 "SAVEPOINT sp1",
1998 "RELEASE SAVEPOINT sp1",
1999 ] {
2000 assert_eq!(
2001 classify_statement(kw),
2002 StatementKind::TransactionControl,
2003 "expected TransactionControl for `{kw}`"
2004 );
2005 }
2006 }
2007
2008 #[test]
2009 fn classify_statement_strips_comments() {
2010 assert_eq!(
2011 classify_statement("/* harmless */ DROP TABLE t"),
2012 StatementKind::Ddl
2013 );
2014 assert_eq!(
2015 classify_statement("-- pretend to be readonly\nINSERT INTO t VALUES (1)"),
2016 StatementKind::Dml
2017 );
2018 }
2019}