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    let stripped = strip_leading_sql_comments(sql);
1710    let first_token: String = stripped
1711        .chars()
1712        .take_while(|c| c.is_alphabetic())
1713        .flat_map(char::to_uppercase)
1714        .collect();
1715    matches!(
1716        first_token.as_str(),
1717        "SELECT" | "WITH" | "EXPLAIN" | "SHOW" | "VALUES"
1718    )
1719}
1720
1721/// Strips leading whitespace, line comments (`--`), and block comments (`/* */`)
1722/// from SQL text. Handles nested block comments.
1723fn strip_leading_sql_comments(sql: &str) -> &str {
1724    let mut s = sql;
1725    loop {
1726        s = s.trim_start();
1727        if s.starts_with("--") {
1728            // Line comment — skip to end of line (handles LF, CRLF, and CR)
1729            match s.find(&['\n', '\r'][..]) {
1730                Some(pos) => {
1731                    let mut next = pos + 1;
1732                    // Handle CRLF: skip both characters
1733                    if s.as_bytes().get(pos) == Some(&b'\r')
1734                        && s.as_bytes().get(pos + 1) == Some(&b'\n')
1735                    {
1736                        next = pos + 2;
1737                    }
1738                    s = &s[next..];
1739                }
1740                None => return "",
1741            }
1742        } else if s.starts_with("/*") {
1743            // Block comment — find matching close, handling nesting
1744            let mut depth = 0u32;
1745            let mut chars = s.char_indices().peekable();
1746            let mut end = None;
1747            while let Some((i, c)) = chars.next() {
1748                if c == '/' && chars.peek().map(|(_, c2)| *c2) == Some('*') {
1749                    chars.next();
1750                    depth += 1;
1751                } else if c == '*' && chars.peek().map(|(_, c2)| *c2) == Some('/') {
1752                    chars.next();
1753                    depth -= 1;
1754                    if depth == 0 {
1755                        end = Some(i + 2);
1756                        break;
1757                    }
1758                }
1759            }
1760            match end {
1761                Some(pos) => s = &s[pos..],
1762                None => return "", // Unclosed comment — no valid SQL
1763            }
1764        } else {
1765            break;
1766        }
1767    }
1768    s
1769}
1770
1771impl Drop for Engine {
1772    fn drop(&mut self) {
1773        // The ephemeral primary is always cleaned up. In daemon mode the
1774        // shared hyperd holds the file handle even after this engine is
1775        // dropped, so we DETACH first (Windows enforces file locks; this
1776        // is a no-op on Unix but keeps behavior identical across platforms).
1777        // The persistent attachment is left in place — its lifetime
1778        // outlives the engine.
1779        if self.daemon_endpoint.is_some() {
1780            let db_name = self.primary_db_name();
1781            let detach = format!("DETACH DATABASE \"{db_name}\"");
1782            let _ = self.connection.execute_command(&detach);
1783        }
1784        // Remove the per-pid temp directory holding the ephemeral file.
1785        // Safe in both daemon and local modes: in local mode the
1786        // HyperProcess Drop tears down hyperd before this fires (Drop
1787        // runs in field-declaration order), so the file is no longer
1788        // open by the time we delete it.
1789        if let Some(parent) = self.ephemeral_path.parent() {
1790            let _ = std::fs::remove_dir_all(parent);
1791        }
1792    }
1793}
1794
1795fn bootstrap_public_schema(connection: &Connection) -> Result<(), McpError> {
1796    connection
1797        .execute_command("CREATE SCHEMA IF NOT EXISTS public")
1798        .map(|_| ())
1799        .map_err(|e| {
1800            McpError::new(
1801                ErrorCode::InternalError,
1802                format!("Failed to bootstrap public schema: {e}"),
1803            )
1804        })
1805}
1806
1807/// Minimal `~/` (and `~\` on Windows) expansion. Resolves the home
1808/// directory via `$HOME` on Unix and `%USERPROFILE%` (falling back to
1809/// `%HOMEDRIVE%%HOMEPATH%`) on Windows. `~username/` is not supported —
1810/// callers who need that should expand their paths themselves.
1811fn shellexpand_tilde(path: &str) -> String {
1812    let rest = if let Some(r) = path.strip_prefix("~/") {
1813        Some(r)
1814    } else if cfg!(windows) {
1815        path.strip_prefix("~\\")
1816    } else {
1817        None
1818    };
1819    let Some(rest) = rest else {
1820        return path.to_string();
1821    };
1822    let Some(home) = home_dir() else {
1823        return path.to_string();
1824    };
1825    let sep = std::path::MAIN_SEPARATOR;
1826    format!("{}{sep}{rest}", home.to_string_lossy())
1827}
1828
1829/// Resolve the user's home directory across platforms. Unix uses `$HOME`;
1830/// Windows prefers `%USERPROFILE%` and falls back to `%HOMEDRIVE%%HOMEPATH%`.
1831fn home_dir() -> Option<PathBuf> {
1832    if cfg!(windows) {
1833        if let Some(profile) = std::env::var_os("USERPROFILE") {
1834            if !profile.is_empty() {
1835                return Some(PathBuf::from(profile));
1836            }
1837        }
1838        let drive = std::env::var_os("HOMEDRIVE")?;
1839        let rel = std::env::var_os("HOMEPATH")?;
1840        let mut combined = PathBuf::from(drive);
1841        combined.push(PathBuf::from(rel));
1842        Some(combined)
1843    } else {
1844        std::env::var_os("HOME").map(PathBuf::from)
1845    }
1846}