Skip to main content

hyperdb_mcp/
table_catalog.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! User-visible catalog of data tables in the workspace.
5//!
6//! Tracks, for every user-facing table:
7//!
8//! | Field              | Who populates |
9//! |--------------------|---------------|
10//! | `table_name`       | MCP (stub)    |
11//! | `load_tool`        | MCP (stub)    |
12//! | `load_params`      | MCP (stub)    |
13//! | `loaded_at`        | MCP (stub)    |
14//! | `last_refreshed_at`| MCP (stub, bumped on every explicit load) |
15//! | `row_count`        | MCP (stub, refreshed opportunistically) |
16//! | `source_url`       | User / LLM via `set_table_metadata` |
17//! | `source_description` | User / LLM |
18//! | `purpose`          | User / LLM |
19//! | `license`          | User / LLM |
20//! | `notes`            | User / LLM |
21//!
22//! The backing table is `_table_catalog`. Each writable database that
23//! receives an ingest gets its own catalog, lazily seeded on first
24//! ingest (or first `set_table_metadata`):
25//!
26//! - **Ephemeral primary writes** stub into the persistent catalog
27//!   (so a long-running session's bookkeeping survives the
28//!   ephemeral file's per-session deletion). Reconciliation cleans
29//!   up rows whose tables no longer exist on engine bootstrap.
30//! - **`database = "persistent"`** writes to the persistent catalog
31//!   directly (same destination as the ephemeral case).
32//! - **`database = "<user-attached-writable>"`** writes to that DB's
33//!   own `_table_catalog`. Read-only attachments never get a catalog.
34//! - **`--ephemeral-only` mode (no persistent attached)** is a
35//!   degraded mode where catalog operations are no-ops because there
36//!   is nowhere durable to put bookkeeping.
37//!
38//! Per-DB catalog routing is exposed via the `*_in` variants
39//! ([`ensure_exists_in`], [`upsert_stub_in`], [`set_metadata_in`],
40//! [`get_in`], [`list_in`], [`delete_for_in`], [`reconcile_in`]). The
41//! original names ([`ensure_exists`], [`upsert_stub`], …) become
42//! 1-line wrappers passing `target_db = None` (which resolves to the
43//! persistent catalog).
44//!
45//! `_table_catalog` is hidden from the user-visible
46//! [`Engine::describe_tables`] output (see
47//! [`crate::engine::is_internal_table`]) so the LLM doesn't see it
48//! alongside its data tables; users who want to inspect it directly
49//! can run `SELECT * FROM "<db>"."public"."_table_catalog"`.
50//!
51//! # Concurrency
52//!
53//! Catalog upserts use an optimistic UPDATE-then-conditional-INSERT
54//! pattern. Hyper rejects PRIMARY KEY ("Index support is disabled")
55//! and `INSERT … ON CONFLICT` ("syntax error: got ON"), but supports
56//! `INSERT … SELECT … WHERE NOT EXISTS (…)` as a single atomic
57//! statement. Because `hyperd` serializes individual statements,
58//! the conditional INSERT can never produce duplicate rows — even
59//! when multiple MCP server processes race to upsert the same
60//! `table_name` concurrently. The UPDATE uses last-writer-wins
61//! semantics for mechanical fields while preserving prose columns
62//! untouched.
63//!
64//! [`HyperMcpServer`]: crate::server::HyperMcpServer
65
66use std::fmt::Write as _;
67
68use crate::engine::{is_internal_table, Engine};
69use crate::error::{ErrorCode, McpError};
70use chrono::{DateTime, Utc};
71use serde::{Deserialize, Serialize};
72use serde_json::Value;
73
74/// Backing table name. Lives in the persistent attachment under
75/// `"persistent"."public"."_table_catalog"`. Hidden from
76/// `Engine::describe_tables`; users who want to inspect it directly can
77/// run a fully-qualified SELECT.
78pub const TABLE_CATALOG_TABLE: &str = "_table_catalog";
79
80/// Returns the fully-qualified SQL reference for `_table_catalog` in
81/// `target_db`, or `None` when no durable destination exists.
82///
83/// Routing:
84/// - `None` → persistent catalog if attached, else `None` (the
85///   `--ephemeral-only` case, where catalog operations are no-ops).
86/// - `Some("persistent")` (case-insensitive) → persistent catalog.
87/// - `Some(alias)` → `"<alias>"."public"."_table_catalog"`. The
88///   caller is responsible for ensuring the catalog table exists in
89///   that DB (typically by calling [`ensure_exists_in`] first); this
90///   function does not validate existence or writability.
91fn qualified_catalog_in(engine: &Engine, target_db: Option<&str>) -> Option<String> {
92    let alias = match target_db {
93        None | Some("") => {
94            if engine.has_persistent() {
95                Engine::PERSISTENT_ALIAS.to_string()
96            } else {
97                return None;
98            }
99        }
100        Some(a) if a.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => {
101            // Persistent is only valid when the engine actually has it.
102            if !engine.has_persistent() {
103                return None;
104            }
105            Engine::PERSISTENT_ALIAS.to_string()
106        }
107        // User-attached alias: lowercase to match the form
108        // `AttachRegistry::attach` stores. Hyper is case-sensitive on
109        // quoted identifiers, so `"MyDB"` and `"mydb"` are different
110        // databases; the registry's lowercase storage is the only
111        // form ATTACH actually used.
112        Some(a) => a.to_ascii_lowercase(),
113    };
114    let alias_esc = alias.replace('"', "\"\"");
115    Some(format!(
116        "\"{alias_esc}\".\"public\".\"{TABLE_CATALOG_TABLE}\""
117    ))
118}
119
120/// One row in [`TABLE_CATALOG_TABLE`].
121///
122/// The `Option` fields are `NULL` in the backing table when not set. Prose
123/// fields (source description, purpose, etc.) stay `None` on auto-stubbed
124/// rows until the user calls `set_table_metadata`.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct CatalogEntry {
127    pub table_name: String,
128    pub source_url: Option<String>,
129    pub source_description: Option<String>,
130    pub purpose: Option<String>,
131    pub load_tool: Option<String>,
132    pub load_params: Option<String>,
133    pub license: Option<String>,
134    pub loaded_at: DateTime<Utc>,
135    pub last_refreshed_at: DateTime<Utc>,
136    pub row_count: Option<i64>,
137    pub notes: Option<String>,
138    pub created_by: Option<String>,
139    pub last_modified_by: Option<String>,
140}
141
142impl CatalogEntry {
143    /// JSON shape returned by the `set_table_metadata` tool and readable
144    /// resources. Times are emitted as RFC 3339 strings so clients don't
145    /// need to know Hyper's internal TIMESTAMP format.
146    #[must_use]
147    pub fn to_json(&self) -> Value {
148        serde_json::json!({
149            "table_name": self.table_name,
150            "source_url": self.source_url,
151            "source_description": self.source_description,
152            "purpose": self.purpose,
153            "load_tool": self.load_tool,
154            "load_params": self.load_params,
155            "license": self.license,
156            "loaded_at": self.loaded_at.to_rfc3339(),
157            "last_refreshed_at": self.last_refreshed_at.to_rfc3339(),
158            "row_count": self.row_count,
159            "notes": self.notes,
160            "created_by": self.created_by,
161            "last_modified_by": self.last_modified_by,
162        })
163    }
164}
165
166/// Partial update payload for [`set_metadata`]. `None` means "leave the
167/// existing value alone"; `Some(String::new())` clears the field.
168#[derive(Debug, Default, Clone)]
169pub struct MetadataFields {
170    pub source_url: Option<String>,
171    pub source_description: Option<String>,
172    pub purpose: Option<String>,
173    pub license: Option<String>,
174    pub notes: Option<String>,
175}
176
177impl MetadataFields {
178    /// `true` if every field is `None`, i.e. the caller supplied nothing
179    /// to update. Used to short-circuit with a clear error instead of
180    /// running a no-op UPDATE.
181    #[must_use]
182    pub fn is_empty(&self) -> bool {
183        self.source_url.is_none()
184            && self.source_description.is_none()
185            && self.purpose.is_none()
186            && self.license.is_none()
187            && self.notes.is_none()
188    }
189}
190
191// --- Table lifecycle --------------------------------------------------------
192
193/// Column specification shared by [`ensure_exists`] and
194/// [`ensure_exists_in_database`]. Kept as a single string constant so
195/// the two DDL paths cannot drift out of sync.
196const CATALOG_COLUMNS: &str = "(\
197     table_name         TEXT NOT NULL, \
198     source_url         TEXT, \
199     source_description TEXT, \
200     purpose            TEXT, \
201     load_tool          TEXT, \
202     load_params        TEXT, \
203     license            TEXT, \
204     loaded_at          TIMESTAMP NOT NULL, \
205     last_refreshed_at  TIMESTAMP NOT NULL, \
206     row_count          BIGINT, \
207     notes              TEXT, \
208     created_by         TEXT, \
209     last_modified_by   TEXT\
210 )";
211
212/// Idempotently create the backing table in `target_db`. Safe to call
213/// on every engine init or every catalog write — if the table
214/// already exists this is a no-op. The schema is the only one the
215/// code targets; all prose columns are nullable, so a plain `NULL`
216/// insert is always well-formed.
217///
218/// Routing follows [`qualified_catalog_in`]: `None` resolves to the
219/// persistent catalog (or no-op in `--ephemeral-only` mode);
220/// `Some("persistent")` is the same; `Some(alias)` targets the
221/// attached database directly.
222///
223/// # Errors
224///
225/// Propagates any error from [`Engine::execute_command`] on the
226/// `CREATE TABLE IF NOT EXISTS` statement — typically connection loss
227/// or permission failures (the latter for read-only attachments;
228/// callers should pre-check writability).
229pub fn ensure_exists_in(engine: &Engine, target_db: Option<&str>) -> Result<(), McpError> {
230    let Some(qualified) = qualified_catalog_in(engine, target_db) else {
231        // Nowhere durable to put the catalog (--ephemeral-only).
232        return Ok(());
233    };
234    let ddl = format!("CREATE TABLE IF NOT EXISTS {qualified} {CATALOG_COLUMNS}");
235    engine.execute_command(&ddl)?;
236    // Migrate pre-existing catalogs: add columns introduced after the
237    // initial schema. Each ALTER is idempotent — if the column already
238    // exists Hyper returns an error that we swallow.
239    for col in ["created_by TEXT", "last_modified_by TEXT"] {
240        let alter = format!("ALTER TABLE {qualified} ADD COLUMN {col}");
241        let _ = engine.execute_command(&alter);
242    }
243    // After CREATE TABLE IF NOT EXISTS the catalog is guaranteed
244    // present in this DB — short-circuit subsequent existence probes.
245    if let Some(alias) = resolve_catalog_alias(engine, target_db) {
246        engine.mark_catalog_present_for(&alias);
247    }
248    Ok(())
249}
250
251/// Backward-compatible wrapper: ensure the catalog exists in the
252/// persistent attachment (or no-op in `--ephemeral-only` mode).
253///
254/// New code should prefer [`ensure_exists_in`] with an explicit
255/// `target_db`.
256///
257/// # Errors
258///
259/// Same as [`ensure_exists_in`].
260pub fn ensure_exists(engine: &Engine) -> Result<(), McpError> {
261    ensure_exists_in(engine, None)
262}
263
264/// Backward-compatible wrapper for the old `ensure_exists_in_database`
265/// name. Delegates to [`ensure_exists_in`].
266///
267/// # Errors
268///
269/// Same as [`ensure_exists_in`].
270#[deprecated(since = "0.2.0", note = "use ensure_exists_in(engine, Some(db_alias))")]
271pub fn ensure_exists_in_database(engine: &Engine, db_alias: &str) -> Result<(), McpError> {
272    ensure_exists_in(engine, Some(db_alias))
273}
274
275// --- Reads ------------------------------------------------------------------
276
277/// Fetch every catalog row from `target_db` in name-sorted order.
278/// Returns an empty `Vec` when no catalog exists in the target.
279///
280/// Routing follows [`qualified_catalog_in`].
281///
282/// # Errors
283///
284/// - Propagates any error from `table_present_in` (connection failure
285///   during the existence probe).
286/// - Propagates any error from [`Engine::execute_query_to_json`].
287/// - Propagates [`ErrorCode::SchemaMismatch`] from `row_to_entry` if
288///   a persisted row cannot be decoded into a [`CatalogEntry`].
289pub fn list_in(engine: &Engine, target_db: Option<&str>) -> Result<Vec<CatalogEntry>, McpError> {
290    let Some(qualified) = qualified_catalog_in(engine, target_db) else {
291        return Ok(Vec::new());
292    };
293    if !table_present_in(engine, target_db)? {
294        return Ok(Vec::new());
295    }
296    let sql = format!(
297        "SELECT table_name, source_url, source_description, purpose, \
298                load_tool, load_params, license, loaded_at, last_refreshed_at, \
299                row_count, notes, created_by, last_modified_by \
300         FROM {qualified} ORDER BY table_name"
301    );
302    let rows = engine.execute_query_to_json(&sql)?;
303    rows.iter().map(row_to_entry).collect()
304}
305
306/// Backward-compatible wrapper: list rows from the persistent catalog.
307///
308/// # Errors
309///
310/// Same as [`list_in`].
311pub fn list(engine: &Engine) -> Result<Vec<CatalogEntry>, McpError> {
312    list_in(engine, None)
313}
314
315/// Fetch a single row from `target_db` by `table_name`, or `Ok(None)`
316/// if absent.
317///
318/// # Errors
319///
320/// Same as [`list_in`].
321pub fn get_in(
322    engine: &Engine,
323    table_name: &str,
324    target_db: Option<&str>,
325) -> Result<Option<CatalogEntry>, McpError> {
326    let Some(qualified) = qualified_catalog_in(engine, target_db) else {
327        return Ok(None);
328    };
329    if !table_present_in(engine, target_db)? {
330        return Ok(None);
331    }
332    let sql = format!(
333        "SELECT table_name, source_url, source_description, purpose, \
334                load_tool, load_params, license, loaded_at, last_refreshed_at, \
335                row_count, notes, created_by, last_modified_by \
336         FROM {qualified} WHERE table_name = {}",
337        sql_literal(table_name)
338    );
339    let rows = engine.execute_query_to_json(&sql)?;
340    match rows.first() {
341        Some(row) => Ok(Some(row_to_entry(row)?)),
342        None => Ok(None),
343    }
344}
345
346/// Backward-compatible wrapper: read from the persistent catalog.
347///
348/// # Errors
349///
350/// Same as [`get_in`].
351pub fn get(engine: &Engine, table_name: &str) -> Result<Option<CatalogEntry>, McpError> {
352    get_in(engine, table_name, None)
353}
354
355// --- Writes -----------------------------------------------------------------
356
357/// Upsert a catalog row for `table_name` in `target_db`, carrying
358/// forward prose fields from any existing row and refreshing
359/// mechanical fields.
360///
361/// Routing follows [`qualified_catalog_in`].
362///
363/// * `load_tool` / `load_params` — overwrite the existing values.
364/// * `row_count` — overwrite.
365/// * `loaded_at` — preserve existing value if the row is already present,
366///   otherwise set to `now`.
367/// * `last_refreshed_at` — set to `now` when `bump_refresh` is `true`,
368///   otherwise preserve the existing value (or `now` for new rows).
369/// * Prose fields (`source_url`, `source_description`, `purpose`,
370///   `license`, `notes`) — preserved unchanged from the existing row.
371///
372/// Implementation uses optimistic concurrency: UPDATE (last writer
373/// wins for existing rows) followed by a conditional INSERT that
374/// only fires when no row exists. Each statement is individually
375/// atomic at the hyperd level, so concurrent upserts from multiple
376/// MCP server processes cannot produce duplicate rows — even without
377/// an external lock or transaction wrapper.
378///
379/// # Errors
380///
381/// - Propagates errors from [`ensure_exists_in`].
382/// - Propagates any error from the UPDATE or INSERT statements —
383///   typically connection-loss failures.
384pub fn upsert_stub_in(
385    engine: &Engine,
386    table_name: &str,
387    load_tool: &str,
388    load_params: Option<&str>,
389    row_count: Option<i64>,
390    bump_refresh: bool,
391    target_db: Option<&str>,
392    client_name: Option<&str>,
393) -> Result<(), McpError> {
394    ensure_exists_in(engine, target_db)?;
395    let Some(qualified) = qualified_catalog_in(engine, target_db) else {
396        return Ok(());
397    };
398
399    let now = Utc::now();
400    let row_count_sql = row_count.map_or_else(|| "NULL".into(), |n: i64| n.to_string());
401    let client_sql = opt_sql_literal(client_name);
402
403    // Step 1: UPDATE mechanical fields on the existing row. Prose
404    // fields are left untouched. `created_by` is never overwritten.
405    let set_clauses = if bump_refresh {
406        let mut s = format!(
407            "load_tool = {}, load_params = {}, row_count = {}, \
408             last_refreshed_at = TIMESTAMP {}",
409            sql_literal(load_tool),
410            opt_sql_literal(load_params),
411            row_count_sql,
412            sql_literal(&format_timestamp(now)),
413        );
414        if let Some(name) = client_name {
415            let _ = write!(s, ", last_modified_by = {}", sql_literal(name));
416        }
417        s
418    } else {
419        let mut s = format!(
420            "load_tool = {}, load_params = {}, row_count = {}",
421            sql_literal(load_tool),
422            opt_sql_literal(load_params),
423            row_count_sql,
424        );
425        if let Some(name) = client_name {
426            let _ = write!(s, ", last_modified_by = {}", sql_literal(name));
427        }
428        s
429    };
430
431    let update_sql = format!(
432        "UPDATE {qualified} SET {set_clauses} WHERE table_name = {}",
433        sql_literal(table_name),
434    );
435    let updated = engine.execute_command(&update_sql)?;
436
437    // Step 2: If no row existed, conditionally INSERT a new one.
438    // The WHERE NOT EXISTS guard prevents duplicates when multiple
439    // processes race to insert the same table_name concurrently —
440    // hyperd serializes individual statements, so exactly one INSERT
441    // will see the absence and succeed.
442    if updated == 0 {
443        let insert_sql = format!(
444            "INSERT INTO {qualified} \
445             (table_name, source_url, source_description, purpose, load_tool, \
446              load_params, license, loaded_at, last_refreshed_at, row_count, notes, \
447              created_by, last_modified_by) \
448             SELECT {name}, NULL, NULL, NULL, {load_tool}, \
449                    {load_params}, NULL, TIMESTAMP {loaded_at}, TIMESTAMP {last_refreshed_at}, \
450                    {row_count}, NULL, {created_by}, {modified_by} \
451             WHERE NOT EXISTS (SELECT 1 FROM {qualified} WHERE table_name = {name})",
452            name = sql_literal(table_name),
453            load_tool = sql_literal(load_tool),
454            load_params = opt_sql_literal(load_params),
455            loaded_at = sql_literal(&format_timestamp(now)),
456            last_refreshed_at = sql_literal(&format_timestamp(now)),
457            row_count = row_count_sql,
458            created_by = client_sql,
459            modified_by = client_sql,
460        );
461        engine.execute_command(&insert_sql)?;
462    }
463    Ok(())
464}
465
466/// Backward-compatible wrapper: upsert into the persistent catalog.
467///
468/// # Errors
469///
470/// Same as [`upsert_stub_in`].
471pub fn upsert_stub(
472    engine: &Engine,
473    table_name: &str,
474    load_tool: &str,
475    load_params: Option<&str>,
476    row_count: Option<i64>,
477    bump_refresh: bool,
478) -> Result<(), McpError> {
479    upsert_stub_in(
480        engine,
481        table_name,
482        load_tool,
483        load_params,
484        row_count,
485        bump_refresh,
486        None,
487        None,
488    )
489}
490
491/// Partial UPDATE of prose fields for one table in `target_db`. Errors
492/// with [`ErrorCode::TableNotFound`] if there is no catalog row for
493/// `table_name` in that DB.
494///
495/// Routing follows [`qualified_catalog_in`]. The catalog is lazily
496/// seeded in `target_db` if absent (matches today's behavior for
497/// the persistent target).
498///
499/// # Errors
500///
501/// - Returns [`ErrorCode::EmptyData`] if `fields` contains no values
502///   to update.
503/// - Returns [`ErrorCode::TableNotFound`] if no catalog row exists for
504///   `table_name` in `target_db`.
505/// - Returns [`ErrorCode::ReadOnlyViolation`] when there is no durable
506///   catalog destination (`--ephemeral-only` with `target_db = None`).
507/// - Propagates any error from [`ensure_exists_in`], [`get_in`], or the
508///   `UPDATE` statement.
509pub fn set_metadata_in(
510    engine: &Engine,
511    table_name: &str,
512    fields: &MetadataFields,
513    target_db: Option<&str>,
514) -> Result<CatalogEntry, McpError> {
515    // Validate caller intent BEFORE seeding the catalog. Both the
516    // empty-fields and missing-row paths return an error; we don't
517    // want them to mutate the target DB's schema as a side effect.
518    if fields.is_empty() {
519        return Err(McpError::new(
520            ErrorCode::EmptyData,
521            "set_table_metadata requires at least one of source_url, \
522             source_description, purpose, license, notes",
523        ));
524    }
525
526    // Require an existing row so we don't accidentally create catalog
527    // entries for tables that don't exist. The server wires the catalog
528    // up to ingest + execute so any real table should already have a
529    // stub row. `get_in` returns None when the catalog table itself
530    // doesn't exist yet; treat that as "no row" without seeding the
531    // catalog (the user gets a clean TableNotFound and the .hyper file
532    // is left untouched).
533    let existing = get_in(engine, table_name, target_db)?.ok_or_else(|| {
534        let where_clause = match target_db {
535            Some(alias) if !alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => {
536                format!(" in database '{alias}'")
537            }
538            _ => String::new(),
539        };
540        McpError::new(
541            ErrorCode::TableNotFound,
542            format!(
543                "No catalog entry for table '{table_name}'{where_clause}. Load the \
544                 table first (load_file / load_data / execute CREATE TABLE) or \
545                 create it and re-run; the catalog is refreshed automatically on \
546                 those paths."
547            ),
548        )
549    })?;
550
551    let mut assignments: Vec<String> = Vec::new();
552    if let Some(v) = &fields.source_url {
553        assignments.push(format!("source_url = {}", sql_literal_or_null_if_empty(v)));
554    }
555    if let Some(v) = &fields.source_description {
556        assignments.push(format!(
557            "source_description = {}",
558            sql_literal_or_null_if_empty(v)
559        ));
560    }
561    if let Some(v) = &fields.purpose {
562        assignments.push(format!("purpose = {}", sql_literal_or_null_if_empty(v)));
563    }
564    if let Some(v) = &fields.license {
565        assignments.push(format!("license = {}", sql_literal_or_null_if_empty(v)));
566    }
567    if let Some(v) = &fields.notes {
568        assignments.push(format!("notes = {}", sql_literal_or_null_if_empty(v)));
569    }
570
571    let qualified = qualified_catalog_in(engine, target_db).ok_or_else(|| {
572        McpError::new(
573            ErrorCode::ReadOnlyViolation,
574            "set_table_metadata is unavailable in --ephemeral-only mode \
575             because the catalog has nowhere durable to live.",
576        )
577    })?;
578    let update_sql = format!(
579        "UPDATE {qualified} SET {assigns} WHERE table_name = {name}",
580        assigns = assignments.join(", "),
581        name = sql_literal(table_name),
582    );
583    engine.execute_command(&update_sql)?;
584
585    // Re-read so the caller gets the canonical view (including unchanged
586    // fields). `existing` is our fallback if the re-read somehow returns
587    // nothing — that shouldn't happen, but preserves the previous row
588    // instead of failing spuriously.
589    get_in(engine, table_name, target_db)?.map_or(Ok(existing), Ok)
590}
591
592/// Backward-compatible wrapper: update prose fields in the persistent
593/// catalog.
594///
595/// # Errors
596///
597/// Same as [`set_metadata_in`].
598pub fn set_metadata(
599    engine: &Engine,
600    table_name: &str,
601    fields: &MetadataFields,
602) -> Result<CatalogEntry, McpError> {
603    set_metadata_in(engine, table_name, fields, None)
604}
605
606/// Delete the catalog row (if any) for `table_name` in `target_db`.
607/// Idempotent — no error if the row doesn't exist.
608///
609/// # Errors
610///
611/// Propagates any error from `table_present_in` or from the `DELETE`.
612pub fn delete_for_in(
613    engine: &Engine,
614    table_name: &str,
615    target_db: Option<&str>,
616) -> Result<bool, McpError> {
617    let Some(qualified) = qualified_catalog_in(engine, target_db) else {
618        return Ok(false);
619    };
620    if !table_present_in(engine, target_db)? {
621        return Ok(false);
622    }
623    let sql = format!(
624        "DELETE FROM {qualified} WHERE table_name = {}",
625        sql_literal(table_name)
626    );
627    let affected = engine.execute_command(&sql)?;
628    Ok(affected > 0)
629}
630
631/// Backward-compatible wrapper: delete from the persistent catalog.
632///
633/// # Errors
634///
635/// Same as [`delete_for_in`].
636pub fn delete_for(engine: &Engine, table_name: &str) -> Result<bool, McpError> {
637    delete_for_in(engine, table_name, None)
638}
639
640/// Synchronize the catalog in `target_db` against the current set of
641/// user tables in that DB.
642///
643/// * Insert a stub row for every user table missing from the catalog.
644///   These stubs use `load_tool = "unknown"` so callers can later tell
645///   the difference between "loaded via a tool and we tracked it" and
646///   "found during reconciliation".
647/// * Delete catalog rows whose table no longer exists in `target_db`.
648/// * Refresh `row_count` on every remaining row.
649///
650/// Does *not* bump `last_refreshed_at` — reconciliation is a housekeeping
651/// pass, not a data refresh.
652///
653/// # Errors
654///
655/// - Propagates any error from [`ensure_exists_in`], `user_tables_in`,
656///   [`list_in`], [`delete_for_in`], `refresh_row_count_in`, or
657///   [`upsert_stub_in`].
658/// - `row_count_of_in` failures are swallowed (the row count falls
659///   back to `None`).
660pub fn reconcile_in(engine: &Engine, target_db: Option<&str>) -> Result<(), McpError> {
661    ensure_exists_in(engine, target_db)?;
662
663    let tables = user_tables_in(engine, target_db)?;
664    let catalog_entries = list_in(engine, target_db)?;
665    let catalog_names: std::collections::HashSet<String> = catalog_entries
666        .iter()
667        .map(|e| e.table_name.clone())
668        .collect();
669    let live_tables: std::collections::HashSet<String> = tables.iter().cloned().collect();
670
671    for entry in &catalog_entries {
672        if !live_tables.contains(&entry.table_name) {
673            delete_for_in(engine, &entry.table_name, target_db)?;
674        }
675    }
676
677    for table in &tables {
678        let row_count = row_count_of_in(engine, table, target_db).ok();
679        if catalog_names.contains(table) {
680            refresh_row_count_in(engine, table, row_count, target_db)?;
681        } else {
682            upsert_stub_in(
683                engine, table, "unknown", None, row_count, false, target_db, None,
684            )?;
685        }
686    }
687    Ok(())
688}
689
690/// Backward-compatible wrapper: reconcile the persistent catalog.
691///
692/// # Errors
693///
694/// Same as [`reconcile_in`].
695pub fn reconcile(engine: &Engine) -> Result<(), McpError> {
696    reconcile_in(engine, None)
697}
698
699// --- Internals --------------------------------------------------------------
700
701/// List user-facing tables in the **persistent** attachment (excludes
702/// `_hyperdb_*` internals and the catalog itself). Returns an empty Vec
703/// when no persistent attachment is present. Uses a fully-qualified
704/// `pg_catalog.pg_tables` probe so it sees inside the attachment;
705/// `Catalog::get_table_names` would target the connection's primary
706/// (ephemeral) by default.
707/// List user-facing tables in `target_db` (excludes `_hyperdb_*`
708/// internals and the catalog itself). Returns an empty Vec when no
709/// catalog destination exists for the target.
710fn user_tables_in(engine: &Engine, target_db: Option<&str>) -> Result<Vec<String>, McpError> {
711    let Some(alias) = resolve_catalog_alias(engine, target_db) else {
712        return Ok(Vec::new());
713    };
714    let alias_esc = alias.replace('"', "\"\"");
715    let sql = format!(
716        "SELECT tablename FROM \"{alias_esc}\".pg_catalog.pg_tables \
717         WHERE schemaname = 'public'"
718    );
719    let rows = engine.execute_query_to_json(&sql)?;
720    Ok(rows
721        .into_iter()
722        .filter_map(|r| {
723            r.get("tablename")
724                .and_then(|v| v.as_str())
725                .map(str::to_string)
726        })
727        .filter(|name| name != TABLE_CATALOG_TABLE && !is_internal_table(name))
728        .collect())
729}
730
731/// `true` when `_table_catalog` is present inside `target_db`.
732/// Returns `false` when no catalog destination exists.
733///
734/// Caches the per-DB result on the engine after the first probe so
735/// subsequent reads/writes skip the existence query. Catalog
736/// mutators ([`ensure_exists_in`]) update the cache directly via
737/// [`Engine::mark_catalog_present_for`].
738fn table_present_in(engine: &Engine, target_db: Option<&str>) -> Result<bool, McpError> {
739    let Some(alias) = resolve_catalog_alias(engine, target_db) else {
740        return Ok(false);
741    };
742    let alias_for_probe = alias.clone();
743    engine.catalog_present_in(&alias, move |engine| {
744        let alias_esc = alias_for_probe.replace('"', "\"\"");
745        let sql = format!(
746            "SELECT tablename FROM \"{alias_esc}\".pg_catalog.pg_tables \
747             WHERE schemaname = 'public' AND tablename = {}",
748            sql_literal(TABLE_CATALOG_TABLE)
749        );
750        let rows = engine.execute_query_to_json(&sql)?;
751        Ok(!rows.is_empty())
752    })
753}
754
755/// Return `COUNT(*)` for a user table inside `target_db`. Quoted to
756/// handle mixed-case or keyword-like names. Returns 0 if no catalog
757/// destination exists.
758fn row_count_of_in(
759    engine: &Engine,
760    table_name: &str,
761    target_db: Option<&str>,
762) -> Result<i64, McpError> {
763    let Some(alias) = resolve_catalog_alias(engine, target_db) else {
764        return Ok(0);
765    };
766    let alias_esc = alias.replace('"', "\"\"");
767    let quoted = table_name.replace('"', "\"\"");
768    let sql = format!("SELECT COUNT(*) AS cnt FROM \"{alias_esc}\".\"public\".\"{quoted}\"");
769    let rows = engine.execute_query_to_json(&sql)?;
770    Ok(rows
771        .first()
772        .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
773        .unwrap_or(0))
774}
775
776/// Cheap UPDATE for just the `row_count` column of an existing row in
777/// `target_db`. Used by [`reconcile_in`].
778fn refresh_row_count_in(
779    engine: &Engine,
780    table_name: &str,
781    row_count: Option<i64>,
782    target_db: Option<&str>,
783) -> Result<(), McpError> {
784    let Some(qualified) = qualified_catalog_in(engine, target_db) else {
785        return Ok(());
786    };
787    let sql = format!(
788        "UPDATE {qualified} SET row_count = {count} WHERE table_name = {name}",
789        count = row_count.map_or_else(|| "NULL".into(), |n| n.to_string()),
790        name = sql_literal(table_name),
791    );
792    engine.execute_command(&sql)?;
793    Ok(())
794}
795
796/// Resolve `target_db` to the canonical alias whose catalog this
797/// operation should target. Mirrors [`qualified_catalog_in`]'s
798/// routing but returns just the alias (not the qualified table
799/// reference) for callers that need to build their own SQL.
800fn resolve_catalog_alias(engine: &Engine, target_db: Option<&str>) -> Option<String> {
801    match target_db {
802        None | Some("") => {
803            if engine.has_persistent() {
804                Some(Engine::PERSISTENT_ALIAS.to_string())
805            } else {
806                None
807            }
808        }
809        Some(a) if a.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => {
810            if engine.has_persistent() {
811                Some(Engine::PERSISTENT_ALIAS.to_string())
812            } else {
813                None
814            }
815        }
816        // User-attached alias: lowercase to match the registry's
817        // canonical storage form (see `AttachRegistry::attach`).
818        Some(a) => Some(a.to_ascii_lowercase()),
819    }
820}
821
822/// Hyper emits TIMESTAMP columns as either `YYYY-MM-DDTHH:MM:SS...` (RFC
823/// 3339) or `YYYY-MM-DD HH:MM:SS[.fff]` depending on path; accept both.
824fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, McpError> {
825    DateTime::parse_from_rfc3339(s)
826        .map(|d| d.with_timezone(&Utc))
827        .or_else(|_| {
828            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f")
829                .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S"))
830                .map(|ndt| ndt.and_utc())
831                .map_err(|e| {
832                    McpError::new(
833                        ErrorCode::InternalError,
834                        format!("Could not parse timestamp '{s}': {e}"),
835                    )
836                })
837        })
838}
839
840/// Format a UTC timestamp for Hyper's `TIMESTAMP 'literal'` syntax.
841fn format_timestamp(ts: DateTime<Utc>) -> String {
842    ts.format("%Y-%m-%d %H:%M:%S%.6f").to_string()
843}
844
845fn row_to_entry(row: &Value) -> Result<CatalogEntry, McpError> {
846    let table_name = row
847        .get("table_name")
848        .and_then(|v| v.as_str())
849        .ok_or_else(|| {
850            McpError::new(
851                ErrorCode::InternalError,
852                "_table_catalog row missing 'table_name'",
853            )
854        })?
855        .to_string();
856    let str_field = |name: &str| row.get(name).and_then(|v| v.as_str()).map(str::to_string);
857    let loaded_at = parse_timestamp(row.get("loaded_at").and_then(|v| v.as_str()).unwrap_or(""))?;
858    let last_refreshed_at = parse_timestamp(
859        row.get("last_refreshed_at")
860            .and_then(|v| v.as_str())
861            .unwrap_or(""),
862    )?;
863    let row_count = row.get("row_count").and_then(serde_json::Value::as_i64);
864    Ok(CatalogEntry {
865        table_name,
866        source_url: str_field("source_url"),
867        source_description: str_field("source_description"),
868        purpose: str_field("purpose"),
869        load_tool: str_field("load_tool"),
870        load_params: str_field("load_params"),
871        license: str_field("license"),
872        loaded_at,
873        last_refreshed_at,
874        row_count,
875        notes: str_field("notes"),
876        created_by: str_field("created_by"),
877        last_modified_by: str_field("last_modified_by"),
878    })
879}
880
881/// Escape a SQL string literal for direct concatenation. Matches the
882/// approach used by `saved_queries::sql_literal` — `'` is doubled per
883/// ANSI SQL, nothing else is special.
884fn sql_literal(s: &str) -> String {
885    format!("'{}'", s.replace('\'', "''"))
886}
887
888/// Same as [`sql_literal`] but treats an empty string as an explicit
889/// clear — rendered as `NULL` rather than `''`. Used on
890/// `set_table_metadata` so callers can wipe a field by passing `""`.
891fn sql_literal_or_null_if_empty(s: &str) -> String {
892    if s.is_empty() {
893        "NULL".into()
894    } else {
895        sql_literal(s)
896    }
897}
898
899/// Render an optional prose value into SQL: `NULL` if `None`, otherwise
900/// the properly-quoted literal. All prose columns in `_table_catalog`
901/// are nullable, so this is always a well-formed INSERT fragment.
902fn opt_sql_literal(v: Option<&str>) -> String {
903    match v {
904        Some(s) => sql_literal(s),
905        None => "NULL".into(),
906    }
907}