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