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