Skip to main content

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(&params)).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}