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