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}