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` (single underscore, *not*
23//! `_hyperdb_`) so it shows up in `describe` and the resource catalog — the
24//! catalog is meant to be read by humans and LLMs, it isn't internal
25//! bookkeeping. The `_` prefix signals "workspace meta" without triggering
26//! [`crate::engine::is_internal_table`]'s hidden-table filter.
27//!
28//! All operations no-op quietly when [`HyperMcpServer`] was constructed with
29//! `--bare`: the module never gets called in that mode, so the table is
30//! never created and the workspace file stays pristine.
31//!
32//! [`HyperMcpServer`]: crate::server::HyperMcpServer
33
34use crate::engine::{is_internal_table, Engine};
35use crate::error::{ErrorCode, McpError};
36use chrono::{DateTime, Utc};
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39
40/// Backing table name. Visible in `describe`; users can `SELECT * FROM
41/// _table_catalog` directly.
42pub const TABLE_CATALOG_TABLE: &str = "_table_catalog";
43
44/// One row in [`TABLE_CATALOG_TABLE`].
45///
46/// The `Option` fields are `NULL` in the backing table when not set. Prose
47/// fields (source description, purpose, etc.) stay `None` on auto-stubbed
48/// rows until the user calls `set_table_metadata`.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct CatalogEntry {
51    pub table_name: String,
52    pub source_url: Option<String>,
53    pub source_description: Option<String>,
54    pub purpose: Option<String>,
55    pub load_tool: Option<String>,
56    pub load_params: Option<String>,
57    pub license: Option<String>,
58    pub loaded_at: DateTime<Utc>,
59    pub last_refreshed_at: DateTime<Utc>,
60    pub row_count: Option<i64>,
61    pub notes: Option<String>,
62}
63
64impl CatalogEntry {
65    /// JSON shape returned by the `set_table_metadata` tool and readable
66    /// resources. Times are emitted as RFC 3339 strings so clients don't
67    /// need to know Hyper's internal TIMESTAMP format.
68    #[must_use]
69    pub fn to_json(&self) -> Value {
70        serde_json::json!({
71            "table_name": self.table_name,
72            "source_url": self.source_url,
73            "source_description": self.source_description,
74            "purpose": self.purpose,
75            "load_tool": self.load_tool,
76            "load_params": self.load_params,
77            "license": self.license,
78            "loaded_at": self.loaded_at.to_rfc3339(),
79            "last_refreshed_at": self.last_refreshed_at.to_rfc3339(),
80            "row_count": self.row_count,
81            "notes": self.notes,
82        })
83    }
84}
85
86/// Partial update payload for [`set_metadata`]. `None` means "leave the
87/// existing value alone"; `Some(String::new())` clears the field.
88#[derive(Debug, Default, Clone)]
89pub struct MetadataFields {
90    pub source_url: Option<String>,
91    pub source_description: Option<String>,
92    pub purpose: Option<String>,
93    pub license: Option<String>,
94    pub notes: Option<String>,
95}
96
97impl MetadataFields {
98    /// `true` if every field is `None`, i.e. the caller supplied nothing
99    /// to update. Used to short-circuit with a clear error instead of
100    /// running a no-op UPDATE.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.source_url.is_none()
104            && self.source_description.is_none()
105            && self.purpose.is_none()
106            && self.license.is_none()
107            && self.notes.is_none()
108    }
109}
110
111// --- Table lifecycle --------------------------------------------------------
112
113/// Column specification shared by [`ensure_exists`] and
114/// [`ensure_exists_in_database`]. Kept as a single string constant so
115/// the two DDL paths cannot drift out of sync.
116const CATALOG_COLUMNS: &str = "(\
117     table_name         TEXT NOT NULL, \
118     source_url         TEXT, \
119     source_description TEXT, \
120     purpose            TEXT, \
121     load_tool          TEXT, \
122     load_params        TEXT, \
123     license            TEXT, \
124     loaded_at          TIMESTAMP NOT NULL, \
125     last_refreshed_at  TIMESTAMP NOT NULL, \
126     row_count          BIGINT, \
127     notes              TEXT\
128 )";
129
130/// Idempotently create the backing table in the primary workspace. Safe
131/// to call on every engine init — if the table already exists this is
132/// a no-op. The schema here is the only one the code targets; all
133/// prose columns are nullable, so a plain `NULL` insert is always
134/// well-formed.
135///
136/// The DDL uses the unqualified name, which — thanks to the
137/// `schema_search_path` pin installed by [`crate::attach::AttachRegistry`]
138/// — always resolves to the primary workspace even while additional
139/// databases are attached.
140///
141/// # Errors
142///
143/// Propagates any error from [`Engine::execute_command`] on the
144/// `CREATE TABLE IF NOT EXISTS` statement — typically connection loss
145/// or permission failures.
146pub fn ensure_exists(engine: &Engine) -> Result<(), McpError> {
147    let ddl = format!("CREATE TABLE IF NOT EXISTS \"{TABLE_CATALOG_TABLE}\" {CATALOG_COLUMNS}");
148    engine.execute_command(&ddl)?;
149    Ok(())
150}
151
152/// Idempotently create `_table_catalog` inside an *attached* database
153/// (fully qualified as `"{db_alias}"."public"."_table_catalog"`), with
154/// the same schema as the primary's catalog.
155///
156/// Used by `attach_database` when the MCP just created a fresh
157/// `.hyper` file via `on_missing: create`: seeding the empty file with
158/// the catalog table makes the new workspace immediately usable as a
159/// primary the next time someone opens it on its own, without paying
160/// a backfill sweep. Only called when the server is not in bare mode;
161/// attaching an *existing* database never touches its `_table_catalog`.
162///
163/// # Errors
164///
165/// Propagates any error from the qualified `CREATE TABLE IF NOT EXISTS`
166/// against the attached database — typically a wire error or a
167/// malformed alias that slipped past validation.
168pub fn ensure_exists_in_database(engine: &Engine, db_alias: &str) -> Result<(), McpError> {
169    let alias_esc = db_alias.replace('"', "\"\"");
170    let ddl = format!(
171        "CREATE TABLE IF NOT EXISTS \"{alias_esc}\".\"public\".\"{TABLE_CATALOG_TABLE}\" \
172         {CATALOG_COLUMNS}"
173    );
174    engine.execute_command(&ddl)?;
175    Ok(())
176}
177
178// --- Reads ------------------------------------------------------------------
179
180/// Fetch every catalog row in name-sorted order. Returns an empty `Vec` if
181/// the catalog table doesn't exist (callers shouldn't need to pre-check).
182///
183/// # Errors
184///
185/// - Propagates any error from `table_present` (connection failure
186///   during the existence probe).
187/// - Propagates any error from [`Engine::execute_query_to_json`].
188/// - Propagates [`ErrorCode::SchemaMismatch`] from `row_to_entry` if
189///   a persisted row cannot be decoded into a [`CatalogEntry`].
190pub fn list(engine: &Engine) -> Result<Vec<CatalogEntry>, McpError> {
191    if !table_present(engine)? {
192        return Ok(Vec::new());
193    }
194    let sql = format!(
195        "SELECT table_name, source_url, source_description, purpose, \
196                load_tool, load_params, license, loaded_at, last_refreshed_at, \
197                row_count, notes \
198         FROM \"{TABLE_CATALOG_TABLE}\" ORDER BY table_name"
199    );
200    let rows = engine.execute_query_to_json(&sql)?;
201    rows.iter().map(row_to_entry).collect()
202}
203
204/// Fetch a single row by `table_name`, or `Ok(None)` if absent.
205///
206/// # Errors
207///
208/// Same as [`list`]: propagates errors from `table_present`, the
209/// Hyper `SELECT`, or row decoding.
210pub fn get(engine: &Engine, table_name: &str) -> Result<Option<CatalogEntry>, McpError> {
211    if !table_present(engine)? {
212        return Ok(None);
213    }
214    let sql = format!(
215        "SELECT table_name, source_url, source_description, purpose, \
216                load_tool, load_params, license, loaded_at, last_refreshed_at, \
217                row_count, notes \
218         FROM \"{TABLE_CATALOG_TABLE}\" WHERE table_name = {}",
219        sql_literal(table_name)
220    );
221    let rows = engine.execute_query_to_json(&sql)?;
222    match rows.first() {
223        Some(row) => Ok(Some(row_to_entry(row)?)),
224        None => Ok(None),
225    }
226}
227
228// --- Writes -----------------------------------------------------------------
229
230/// Upsert a catalog row for `table_name`, carrying forward prose fields
231/// from any existing row and refreshing mechanical fields.
232///
233/// * `load_tool` / `load_params` — overwrite the existing values.
234/// * `row_count` — overwrite.
235/// * `loaded_at` — preserve existing value if the row is already present,
236///   otherwise set to `now`.
237/// * `last_refreshed_at` — set to `now` when `bump_refresh` is `true`,
238///   otherwise preserve the existing value (or `now` for new rows).
239/// * Prose fields (`source_url`, `source_description`, `purpose`,
240///   `license`, `notes`) — preserved unchanged from the existing row.
241///
242/// Implementation is DELETE + INSERT (Hyper lacks `UPSERT`), run inside a
243/// transaction for atomicity.
244///
245/// # Errors
246///
247/// - Propagates errors from [`ensure_exists`] and [`get`] (catalog
248///   probe and read failures).
249/// - Propagates any transaction error from the enclosing
250///   [`Engine::execute_in_transaction`] — typically DELETE, INSERT,
251///   commit, or connection-loss failures.
252pub fn upsert_stub(
253    engine: &Engine,
254    table_name: &str,
255    load_tool: &str,
256    load_params: Option<&str>,
257    row_count: Option<i64>,
258    bump_refresh: bool,
259) -> Result<(), McpError> {
260    ensure_exists(engine)?;
261
262    let existing = get(engine, table_name)?;
263    let now = Utc::now();
264    let loaded_at = existing.as_ref().map_or(now, |e| e.loaded_at);
265    let last_refreshed_at = if bump_refresh {
266        now
267    } else {
268        existing.as_ref().map_or(now, |e| e.last_refreshed_at)
269    };
270
271    let (source_url, source_description, purpose, license, notes) = match existing.as_ref() {
272        Some(e) => (
273            e.source_url.clone(),
274            e.source_description.clone(),
275            e.purpose.clone(),
276            e.license.clone(),
277            e.notes.clone(),
278        ),
279        None => (None, None, None, None, None),
280    };
281
282    engine.execute_in_transaction(|engine| {
283        let delete_sql = format!(
284            "DELETE FROM \"{TABLE_CATALOG_TABLE}\" WHERE table_name = {}",
285            sql_literal(table_name)
286        );
287        engine.execute_command(&delete_sql)?;
288
289        let insert_sql = format!(
290            "INSERT INTO \"{TABLE_CATALOG_TABLE}\" \
291             (table_name, source_url, source_description, purpose, load_tool, \
292              load_params, license, loaded_at, last_refreshed_at, row_count, notes) \
293             VALUES ({name}, {source_url}, {source_description}, {purpose}, {load_tool}, \
294                     {load_params}, {license}, TIMESTAMP {loaded_at}, TIMESTAMP {last_refreshed_at}, \
295                     {row_count}, {notes})",
296            name = sql_literal(table_name),
297            source_url = opt_sql_literal(source_url.as_deref()),
298            source_description = opt_sql_literal(source_description.as_deref()),
299            purpose = opt_sql_literal(purpose.as_deref()),
300            load_tool = sql_literal(load_tool),
301            load_params = opt_sql_literal(load_params),
302            license = opt_sql_literal(license.as_deref()),
303            loaded_at = sql_literal(&format_timestamp(loaded_at)),
304            last_refreshed_at = sql_literal(&format_timestamp(last_refreshed_at)),
305            row_count = row_count.map_or_else(|| "NULL".into(), |n| n.to_string()),
306            notes = opt_sql_literal(notes.as_deref()),
307        );
308
309        engine.execute_command(&insert_sql)?;
310        Ok(())
311    })?;
312    Ok(())
313}
314
315/// Partial UPDATE of prose fields for one table. Errors with
316/// [`ErrorCode::TableNotFound`] if there is no catalog row for
317/// `table_name` (callers can decide whether to first stub via
318/// [`upsert_stub`] or surface the error).
319///
320/// # Errors
321///
322/// - Returns [`ErrorCode::EmptyData`] if `fields` contains no values
323///   to update.
324/// - Returns [`ErrorCode::TableNotFound`] if no catalog row exists for
325///   `table_name`.
326/// - Propagates any error from [`ensure_exists`], [`get`], or the
327///   `UPDATE` statement.
328pub fn set_metadata(
329    engine: &Engine,
330    table_name: &str,
331    fields: &MetadataFields,
332) -> Result<CatalogEntry, McpError> {
333    ensure_exists(engine)?;
334
335    if fields.is_empty() {
336        return Err(McpError::new(
337            ErrorCode::EmptyData,
338            "set_table_metadata requires at least one of source_url, \
339             source_description, purpose, license, notes",
340        ));
341    }
342
343    // Require an existing row so we don't accidentally create catalog
344    // entries for tables that don't exist. The server wires the catalog
345    // up to ingest + execute so any real table should already have a
346    // stub row.
347    let existing = get(engine, table_name)?.ok_or_else(|| {
348        McpError::new(
349            ErrorCode::TableNotFound,
350            format!(
351                "No catalog entry for table '{table_name}'. Load the table first \
352                 (load_file / load_data / execute CREATE TABLE) or create it and \
353                 re-run; the catalog is refreshed automatically on those paths."
354            ),
355        )
356    })?;
357
358    let mut assignments: Vec<String> = Vec::new();
359    if let Some(v) = &fields.source_url {
360        assignments.push(format!("source_url = {}", sql_literal_or_null_if_empty(v)));
361    }
362    if let Some(v) = &fields.source_description {
363        assignments.push(format!(
364            "source_description = {}",
365            sql_literal_or_null_if_empty(v)
366        ));
367    }
368    if let Some(v) = &fields.purpose {
369        assignments.push(format!("purpose = {}", sql_literal_or_null_if_empty(v)));
370    }
371    if let Some(v) = &fields.license {
372        assignments.push(format!("license = {}", sql_literal_or_null_if_empty(v)));
373    }
374    if let Some(v) = &fields.notes {
375        assignments.push(format!("notes = {}", sql_literal_or_null_if_empty(v)));
376    }
377
378    let update_sql = format!(
379        "UPDATE \"{TABLE_CATALOG_TABLE}\" SET {assigns} WHERE table_name = {name}",
380        assigns = assignments.join(", "),
381        name = sql_literal(table_name),
382    );
383    engine.execute_command(&update_sql)?;
384
385    // Re-read so the caller gets the canonical view (including unchanged
386    // fields). `existing` is our fallback if the re-read somehow returns
387    // nothing — that shouldn't happen, but preserves the previous row
388    // instead of failing spuriously.
389    get(engine, table_name)?.map_or(Ok(existing), Ok)
390}
391
392/// Delete the catalog row (if any) for `table_name`. Called when a table
393/// is dropped. Idempotent — no error if the row doesn't exist.
394///
395/// # Errors
396///
397/// Propagates any error from `table_present` or from the `DELETE`
398/// statement against the catalog table.
399pub fn delete_for(engine: &Engine, table_name: &str) -> Result<bool, McpError> {
400    if !table_present(engine)? {
401        return Ok(false);
402    }
403    let sql = format!(
404        "DELETE FROM \"{TABLE_CATALOG_TABLE}\" WHERE table_name = {}",
405        sql_literal(table_name)
406    );
407    let affected = engine.execute_command(&sql)?;
408    Ok(affected > 0)
409}
410
411/// Synchronize the catalog against the current set of user tables.
412///
413/// * Insert a stub row for every user table missing from the catalog.
414///   These stubs use `load_tool = "unknown"` so callers can later tell
415///   the difference between "loaded via a tool and we tracked it" and
416///   "found during reconciliation".
417/// * Delete catalog rows whose table no longer exists in Hyper.
418/// * Refresh `row_count` on every remaining row so `SELECT * FROM
419///   _table_catalog` always reflects current size (cheap: it's one
420///   `COUNT(*)` per table, and the user set is small).
421///
422/// Does *not* bump `last_refreshed_at` — reconciliation is a housekeeping
423/// pass, not a data refresh. Only explicit loads mark a refresh.
424///
425/// # Errors
426///
427/// - Propagates any error from [`ensure_exists`], `user_tables`,
428///   [`list`], [`delete_for`], `refresh_row_count`, or [`upsert_stub`].
429/// - `row_count_of` failures are swallowed (the row count falls back
430///   to `None`), so per-table count probe failures do not abort the
431///   sweep.
432pub fn reconcile(engine: &Engine) -> Result<(), McpError> {
433    ensure_exists(engine)?;
434
435    let tables = user_tables(engine)?;
436    let catalog_entries = list(engine)?;
437    let catalog_names: std::collections::HashSet<String> = catalog_entries
438        .iter()
439        .map(|e| e.table_name.clone())
440        .collect();
441    let live_tables: std::collections::HashSet<String> = tables.iter().cloned().collect();
442
443    for entry in &catalog_entries {
444        if !live_tables.contains(&entry.table_name) {
445            delete_for(engine, &entry.table_name)?;
446        }
447    }
448
449    for table in &tables {
450        let row_count = row_count_of(engine, table).ok();
451        if catalog_names.contains(table) {
452            refresh_row_count(engine, table, row_count)?;
453        } else {
454            upsert_stub(engine, table, "unknown", None, row_count, false)?;
455        }
456    }
457    Ok(())
458}
459
460// --- Internals --------------------------------------------------------------
461
462/// List user-facing tables (excludes `_hyperdb_*` internals and the
463/// catalog itself).
464fn user_tables(engine: &Engine) -> Result<Vec<String>, McpError> {
465    let describe = engine.describe_tables()?;
466    let mut names = Vec::new();
467    for table in describe {
468        if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
469            if name == TABLE_CATALOG_TABLE || is_internal_table(name) {
470                continue;
471            }
472            names.push(name.to_string());
473        }
474    }
475    Ok(names)
476}
477
478/// `true` when `_table_catalog` is already present in the workspace.
479/// Used by read paths to return an empty result instead of erroring on a
480/// brand-new workspace where the table hasn't been created yet.
481fn table_present(engine: &Engine) -> Result<bool, McpError> {
482    let describe = engine.describe_tables()?;
483    Ok(describe.iter().any(|t| {
484        t.get("name")
485            .and_then(|v| v.as_str())
486            .is_some_and(|n| n == TABLE_CATALOG_TABLE)
487    }))
488}
489
490/// Return `COUNT(*)` for a user table. Quoted to handle mixed-case or
491/// keyword-like names. The `describe_tables` path already gives us a row
492/// count, but only after a full schema read; this dedicated query is
493/// cheaper when we only need the number.
494fn row_count_of(engine: &Engine, table_name: &str) -> Result<i64, McpError> {
495    let quoted = table_name.replace('"', "\"\"");
496    let sql = format!("SELECT COUNT(*) AS cnt FROM \"{quoted}\"");
497    let rows = engine.execute_query_to_json(&sql)?;
498    Ok(rows
499        .first()
500        .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
501        .unwrap_or(0))
502}
503
504/// Cheap UPDATE for just the `row_count` column of an existing row.
505/// Used by [`reconcile`] so we don't rewrite the whole row just to
506/// refresh counts.
507fn refresh_row_count(
508    engine: &Engine,
509    table_name: &str,
510    row_count: Option<i64>,
511) -> Result<(), McpError> {
512    let sql = format!(
513        "UPDATE \"{TABLE_CATALOG_TABLE}\" SET row_count = {count} WHERE table_name = {name}",
514        count = row_count.map_or_else(|| "NULL".into(), |n| n.to_string()),
515        name = sql_literal(table_name),
516    );
517    engine.execute_command(&sql)?;
518    Ok(())
519}
520
521/// Hyper emits TIMESTAMP columns as either `YYYY-MM-DDTHH:MM:SS...` (RFC
522/// 3339) or `YYYY-MM-DD HH:MM:SS[.fff]` depending on path; accept both.
523fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, McpError> {
524    DateTime::parse_from_rfc3339(s)
525        .map(|d| d.with_timezone(&Utc))
526        .or_else(|_| {
527            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f")
528                .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S"))
529                .map(|ndt| ndt.and_utc())
530                .map_err(|e| {
531                    McpError::new(
532                        ErrorCode::InternalError,
533                        format!("Could not parse timestamp '{s}': {e}"),
534                    )
535                })
536        })
537}
538
539/// Format a UTC timestamp for Hyper's `TIMESTAMP 'literal'` syntax.
540fn format_timestamp(ts: DateTime<Utc>) -> String {
541    ts.format("%Y-%m-%d %H:%M:%S%.6f").to_string()
542}
543
544fn row_to_entry(row: &Value) -> Result<CatalogEntry, McpError> {
545    let table_name = row
546        .get("table_name")
547        .and_then(|v| v.as_str())
548        .ok_or_else(|| {
549            McpError::new(
550                ErrorCode::InternalError,
551                "_table_catalog row missing 'table_name'",
552            )
553        })?
554        .to_string();
555    let str_field = |name: &str| row.get(name).and_then(|v| v.as_str()).map(str::to_string);
556    let loaded_at = parse_timestamp(row.get("loaded_at").and_then(|v| v.as_str()).unwrap_or(""))?;
557    let last_refreshed_at = parse_timestamp(
558        row.get("last_refreshed_at")
559            .and_then(|v| v.as_str())
560            .unwrap_or(""),
561    )?;
562    let row_count = row.get("row_count").and_then(serde_json::Value::as_i64);
563    Ok(CatalogEntry {
564        table_name,
565        source_url: str_field("source_url"),
566        source_description: str_field("source_description"),
567        purpose: str_field("purpose"),
568        load_tool: str_field("load_tool"),
569        load_params: str_field("load_params"),
570        license: str_field("license"),
571        loaded_at,
572        last_refreshed_at,
573        row_count,
574        notes: str_field("notes"),
575    })
576}
577
578/// Escape a SQL string literal for direct concatenation. Matches the
579/// approach used by `saved_queries::sql_literal` — `'` is doubled per
580/// ANSI SQL, nothing else is special.
581fn sql_literal(s: &str) -> String {
582    format!("'{}'", s.replace('\'', "''"))
583}
584
585/// Same as [`sql_literal`] but treats an empty string as an explicit
586/// clear — rendered as `NULL` rather than `''`. Used on
587/// `set_table_metadata` so callers can wipe a field by passing `""`.
588fn sql_literal_or_null_if_empty(s: &str) -> String {
589    if s.is_empty() {
590        "NULL".into()
591    } else {
592        sql_literal(s)
593    }
594}
595
596/// Render an optional prose value into SQL: `NULL` if `None`, otherwise
597/// the properly-quoted literal. All prose columns in `_table_catalog`
598/// are nullable, so this is always a well-formed INSERT fragment.
599fn opt_sql_literal(v: Option<&str>) -> String {
600    match v {
601        Some(s) => sql_literal(s),
602        None => "NULL".into(),
603    }
604}