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