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}