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