use hyperdb_mcp::attach::{
validate_alias, validate_local_path, AttachRegistry, AttachRequest, AttachSource, OnMissing,
LOCAL_ALIAS,
};
use hyperdb_mcp::engine::Engine;
use hyperdb_mcp::error::ErrorCode;
use tempfile::TempDir;
fn primary_workspace() -> (Engine, TempDir) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("primary.hyper");
let engine = Engine::new_no_daemon(Some(path.to_string_lossy().into())).unwrap();
engine
.execute_command("CREATE TABLE primary_t (x INT)")
.unwrap();
engine
.execute_command("INSERT INTO primary_t VALUES (1), (2)")
.unwrap();
(engine, dir)
}
fn build_source_hyper_file(dir: &TempDir, name: &str, rows: &[(i32, &str)]) -> std::path::PathBuf {
let path = dir.path().join(name);
{
let engine = Engine::new_no_daemon(Some(path.to_string_lossy().into())).unwrap();
engine
.execute_command("CREATE TABLE \"persistent\".\"public\".\"t\" (a INT, b TEXT)")
.unwrap();
for (a, b) in rows {
let escaped = b.replace('\'', "''");
engine
.execute_command(&format!(
"INSERT INTO \"persistent\".\"public\".\"t\" VALUES ({a}, '{escaped}')"
))
.unwrap();
}
}
path
}
fn row_count(engine: &Engine, qualified: &str) -> i64 {
let sql = format!("SELECT COUNT(*) AS cnt FROM {qualified}");
engine
.execute_query_to_json(&sql)
.unwrap()
.first()
.and_then(|r| r.get("cnt").and_then(serde_json::value::Value::as_i64))
.unwrap()
}
#[test]
fn attach_then_query_attached_table() {
let (engine, dir) = primary_workspace();
let source = build_source_hyper_file(&dir, "source.hyper", &[(10, "x"), (20, "y"), (30, "z")]);
let registry = AttachRegistry::new();
let entry = registry
.attach(
&engine,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile {
path: source.clone(),
},
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
assert_eq!(entry.alias, "src");
assert!(!entry.writable);
let rows = engine
.execute_query_to_json("SELECT a FROM \"src\".public.t ORDER BY a")
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["a"], 10);
}
#[test]
fn attach_same_alias_twice_errors() {
let (engine, dir) = primary_workspace();
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "a")]);
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile {
path: source.clone(),
},
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
let err = registry
.attach(
&engine,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile { path: source },
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidArgument);
}
#[test]
fn detach_removes_entry_and_hides_tables() {
let (engine, dir) = primary_workspace();
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "a")]);
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile { path: source },
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
assert_eq!(registry.list().len(), 1);
let detached = registry.detach(&engine, "src").unwrap();
assert!(detached);
assert!(registry.list().is_empty());
let err = engine
.execute_query_to_json("SELECT 1 FROM \"src\".public.t LIMIT 0")
.unwrap_err();
assert!(!err.message.is_empty());
}
#[test]
fn detach_unknown_alias_returns_false_without_error() {
let (engine, _dir) = primary_workspace();
let registry = AttachRegistry::new();
let detached = registry.detach(&engine, "not_attached").unwrap();
assert!(!detached);
}
#[test]
fn validator_reserves_local_alias() {
assert_eq!(
validate_alias(LOCAL_ALIAS).unwrap_err().code,
ErrorCode::InvalidArgument
);
}
#[test]
fn validator_accepts_well_formed_absolute_path() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("file.hyper");
std::fs::write(&f, b"").unwrap();
let canonical = validate_local_path(f.to_str().unwrap()).unwrap();
assert_eq!(canonical, std::fs::canonicalize(&f).unwrap());
}
#[test]
fn validator_rejects_relative_path() {
let err = validate_local_path("some/relative/path.hyper").unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidArgument);
}
#[test]
fn attach_on_missing_create_builds_file_and_attaches_writable() {
let (engine, dir) = primary_workspace();
let target = dir.path().join("fresh.hyper");
assert!(!target.exists(), "precondition: target must not exist");
let registry = AttachRegistry::new();
let entry = registry
.attach(
&engine,
AttachRequest {
alias: "fresh".into(),
source: AttachSource::LocalFile {
path: target.clone(),
},
writable: true,
on_missing: OnMissing::Create,
},
)
.expect("attach should create the file and succeed");
assert_eq!(entry.alias, "fresh");
assert!(entry.writable);
assert!(
target.exists(),
"attach should have created the .hyper file"
);
engine
.execute_command("CREATE TABLE \"fresh\".public.t (x INT)")
.unwrap();
engine
.execute_command("INSERT INTO \"fresh\".public.t VALUES (42)")
.unwrap();
let rows = engine
.execute_query_to_json("SELECT x FROM \"fresh\".public.t")
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["x"], 42);
}
#[test]
fn attach_on_missing_create_requires_writable() {
let (engine, dir) = primary_workspace();
let target = dir.path().join("fresh.hyper");
let registry = AttachRegistry::new();
let err = registry
.attach(
&engine,
AttachRequest {
alias: "fresh".into(),
source: AttachSource::LocalFile {
path: target.clone(),
},
writable: false,
on_missing: OnMissing::Create,
},
)
.unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidArgument);
assert!(
err.message.to_lowercase().contains("writable"),
"error should mention the writable requirement, got: {}",
err.message
);
assert!(
!target.exists(),
"create must not have run when guard rejects"
);
}
#[test]
fn attach_on_missing_create_is_idempotent_for_existing_file() {
let (engine, dir) = primary_workspace();
let target = build_source_hyper_file(&dir, "existing.hyper", &[(7, "seven")]);
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "ex".into(),
source: AttachSource::LocalFile {
path: target.clone(),
},
writable: true,
on_missing: OnMissing::Create,
},
)
.expect("attach should succeed when file already exists");
let rows = engine
.execute_query_to_json("SELECT a FROM \"ex\".public.t ORDER BY a")
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["a"], 7);
}
fn attached_db_has_table_catalog(engine: &Engine, alias: &str) -> bool {
let alias_esc = alias.replace('"', "\"\"");
let sql = format!(
"SELECT COUNT(*) AS cnt FROM \"{alias_esc}\".pg_catalog.pg_tables \
WHERE schemaname = 'public' AND tablename = '_table_catalog'"
);
engine
.execute_query_to_json(&sql)
.unwrap()
.first()
.and_then(|r| r.get("cnt").and_then(serde_json::value::Value::as_i64))
.is_some_and(|n| n > 0)
}
#[test]
fn on_missing_create_seeds_table_catalog_by_default() {
let (engine, dir) = primary_workspace();
let target = dir.path().join("seeded.hyper");
assert!(!target.exists());
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "seeded".into(),
source: AttachSource::LocalFile {
path: target.clone(),
},
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
assert!(
attached_db_has_table_catalog(&engine, "seeded"),
"non-bare policy should seed _table_catalog in the freshly created attached DB"
);
let rows = engine
.execute_query_to_json("SELECT COUNT(*) AS cnt FROM \"seeded\".public._table_catalog")
.unwrap();
assert_eq!(
rows[0]["cnt"], 0,
"seeded _table_catalog should start empty"
);
}
#[test]
fn attaching_existing_database_never_seeds_catalog() {
let (engine, dir) = primary_workspace();
let target = build_source_hyper_file(&dir, "foreign.hyper", &[(1, "a"), (2, "b")]);
let registry = AttachRegistry::new(); registry
.attach(
&engine,
AttachRequest {
alias: "foreign".into(),
source: AttachSource::LocalFile {
path: target.clone(),
},
writable: true,
on_missing: OnMissing::Error,
},
)
.unwrap();
assert!(
!attached_db_has_table_catalog(&engine, "foreign"),
"existing DB must keep its original schema; no catalog should be added on attach"
);
}
#[test]
fn on_missing_create_on_existing_file_does_not_seed() {
let (engine, dir) = primary_workspace();
let target = build_source_hyper_file(&dir, "preexisting.hyper", &[(3, "three")]);
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "pre".into(),
source: AttachSource::LocalFile {
path: target.clone(),
},
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
assert!(
!attached_db_has_table_catalog(&engine, "pre"),
"on_missing=create pointing at an existing file must not mutate it — \
seeding only fires when CREATE DATABASE actually ran"
);
let rows = engine
.execute_query_to_json("SELECT a FROM \"pre\".public.t ORDER BY a")
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["a"], 3);
}
#[test]
fn validator_for_create_accepts_missing_file_with_existing_parent() {
use hyperdb_mcp::attach::validate_local_path_for_create;
let dir = TempDir::new().unwrap();
let f = dir.path().join("not-yet.hyper");
let resolved = validate_local_path_for_create(f.to_str().unwrap()).unwrap();
assert_eq!(
resolved,
std::fs::canonicalize(dir.path())
.unwrap()
.join("not-yet.hyper")
);
}
#[test]
fn validator_for_create_rejects_missing_parent_dir() {
use hyperdb_mcp::attach::validate_local_path_for_create;
let missing_parent = std::env::temp_dir()
.join("hyper_mcp_definitely_missing_dir_99999")
.join("new.hyper");
let err = validate_local_path_for_create(missing_parent.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, ErrorCode::FileNotFound);
}
#[test]
fn validator_for_create_rejects_relative_path() {
use hyperdb_mcp::attach::validate_local_path_for_create;
let err = validate_local_path_for_create("not/absolute.hyper").unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidArgument);
}
#[test]
fn copy_create_from_attached_source() {
let (engine, dir) = primary_workspace();
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "x"), (2, "y"), (3, "z")]);
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile { path: source },
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
let primary_db = engine.primary_db_name();
let target = format!("\"{primary_db}\".\"public\".\"imported\"");
engine
.execute_command(&format!(
"CREATE TABLE {target} AS SELECT a, b FROM \"src\".public.t WHERE a >= 2",
))
.unwrap();
assert_eq!(row_count(&engine, &target), 2);
engine
.execute_command(&format!(
"INSERT INTO {target} SELECT a, b FROM \"src\".public.t WHERE a >= 2",
))
.unwrap();
assert_eq!(row_count(&engine, &target), 4);
engine
.execute_command(&format!("DROP TABLE IF EXISTS {target}"))
.unwrap();
engine
.execute_command(&format!(
"CREATE TABLE {target} AS SELECT a, b FROM \"src\".public.t WHERE a = 1",
))
.unwrap();
assert_eq!(row_count(&engine, &target), 1);
}
#[test]
fn replay_reattaches_on_fresh_engine() {
let dir = TempDir::new().unwrap();
let primary_path = dir.path().join("primary.hyper");
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "a"), (2, "b")]);
let registry = AttachRegistry::new();
{
let engine_a = Engine::new_no_daemon(Some(primary_path.to_string_lossy().into())).unwrap();
registry
.attach(
&engine_a,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile {
path: source.clone(),
},
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
}
let engine_b = Engine::new_no_daemon(Some(primary_path.to_string_lossy().into())).unwrap();
assert!(engine_b
.execute_query_to_json("SELECT 1 FROM \"src\".public.t LIMIT 0")
.is_err());
registry.replay_all(&engine_b).unwrap();
let rows = engine_b
.execute_query_to_json("SELECT a FROM \"src\".public.t ORDER BY a")
.unwrap();
assert_eq!(rows.len(), 2);
let list = registry.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].alias, "src");
}
#[test]
fn copy_create_stubs_table_catalog_on_primary_workspace() {
use hyperdb_mcp::table_catalog::{self, TABLE_CATALOG_TABLE};
let (engine, _dir) = primary_workspace();
table_catalog::ensure_exists(&engine).unwrap();
let primary_alias = engine.primary_db_name();
let qualified_target = format!("\"{primary_alias}\".\"public\".\"derived\"");
engine
.execute_command(&format!(
"CREATE TABLE {qualified_target} AS SELECT x AS col FROM primary_t WHERE x >= 1"
))
.unwrap();
table_catalog::upsert_stub(
&engine,
"derived",
"copy_query",
Some(r#"{"mode":"create","target_database":null,"target_table":"derived","sql":"SELECT x AS col FROM primary_t WHERE x >= 1"}"#),
Some(2),
true,
)
.unwrap();
let entry = table_catalog::get(&engine, "derived").unwrap().unwrap();
assert_eq!(entry.load_tool.as_deref(), Some("copy_query"));
assert_eq!(entry.row_count, Some(2));
let params = entry.load_params.unwrap();
assert!(
params.contains("\"mode\":\"create\""),
"load_params should echo mode: {params}"
);
assert!(
params.contains("\"target_table\":\"derived\""),
"load_params should echo target_table: {params}"
);
let catalog_present = engine
.execute_query_to_json(
"SELECT tablename FROM \"persistent\".pg_catalog.pg_tables \
WHERE schemaname = 'public' AND tablename = '_table_catalog'",
)
.unwrap();
assert!(
!catalog_present.is_empty(),
"_table_catalog must be present in the persistent attachment"
);
let names: Vec<String> = engine
.describe_tables()
.unwrap()
.iter()
.filter_map(|t| t.get("name").and_then(|v| v.as_str()).map(str::to_string))
.collect();
assert!(names.iter().any(|n| n == "derived"));
let _ = TABLE_CATALOG_TABLE; }
#[test]
fn replay_drops_missing_files() {
let dir = TempDir::new().unwrap();
let primary_path = dir.path().join("primary.hyper");
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "a")]);
let registry = AttachRegistry::new();
{
let engine_a = Engine::new_no_daemon(Some(primary_path.to_string_lossy().into())).unwrap();
registry
.attach(
&engine_a,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile {
path: source.clone(),
},
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
}
std::fs::remove_file(&source).unwrap();
let engine_b = Engine::new_no_daemon(Some(primary_path.to_string_lossy().into())).unwrap();
registry.replay_all(&engine_b).unwrap();
assert!(registry.list().is_empty());
}
#[test]
fn attach_preserves_unqualified_access_to_primary() {
let (engine, dir) = primary_workspace();
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "a")]);
let before = row_count(&engine, "primary_t");
assert_eq!(before, 2);
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile { path: source },
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
let after = row_count(&engine, "primary_t");
assert_eq!(after, 2);
let names: Vec<String> = engine
.describe_tables()
.unwrap()
.iter()
.filter_map(|t| t.get("name").and_then(|v| v.as_str()).map(str::to_string))
.collect();
assert!(
names.iter().any(|n| n == "primary_t"),
"primary_t should remain visible to describe_tables while an \
attachment is live; got {names:?}"
);
}
#[test]
fn detach_resets_schema_search_path_and_preserves_primary_access() {
let (engine, dir) = primary_workspace();
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "a")]);
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile { path: source },
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
assert!(registry.detach(&engine, "src").unwrap());
assert!(registry.list().is_empty());
let rows = engine
.execute_query_to_json("SHOW schema_search_path")
.unwrap();
let setting = rows[0]
.get("schema_search_path")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
setting,
engine.primary_db_name(),
"last detach should pin to primary while persistent stays attached; got {setting:?}"
);
assert_eq!(row_count(&engine, "primary_t"), 2);
}
#[test]
fn detach_one_of_many_keeps_search_path_pinned() {
let (engine, dir) = primary_workspace();
let source_a = build_source_hyper_file(&dir, "src_a.hyper", &[(1, "a")]);
let source_b = build_source_hyper_file(&dir, "src_b.hyper", &[(2, "b")]);
let registry = AttachRegistry::new();
for (alias, path) in [("src_a", source_a), ("src_b", source_b)] {
registry
.attach(
&engine,
AttachRequest {
alias: alias.into(),
source: AttachSource::LocalFile { path },
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
}
assert!(registry.detach(&engine, "src_a").unwrap());
assert_eq!(row_count(&engine, "primary_t"), 2);
let rows = engine
.execute_query_to_json("SHOW schema_search_path")
.unwrap();
let setting = rows[0]
.get("schema_search_path")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_ne!(
setting, "\"$single\"",
"search_path must stay pinned while attachments remain; got {setting:?}"
);
}
#[test]
fn replay_restores_schema_search_path_pin() {
let dir = TempDir::new().unwrap();
let primary_path = dir.path().join("primary.hyper");
{
let engine = Engine::new_no_daemon(Some(primary_path.to_string_lossy().into())).unwrap();
engine
.execute_command("CREATE TABLE \"persistent\".\"public\".\"primary_t\" (x INT)")
.unwrap();
engine
.execute_command(
"INSERT INTO \"persistent\".\"public\".\"primary_t\" VALUES (1), (2), (3)",
)
.unwrap();
}
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "a")]);
let registry = AttachRegistry::new();
{
let engine_a = Engine::new_no_daemon(Some(primary_path.to_string_lossy().into())).unwrap();
registry
.attach(
&engine_a,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile {
path: source.clone(),
},
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
}
let engine_b = Engine::new_no_daemon(Some(primary_path.to_string_lossy().into())).unwrap();
registry.replay_all(&engine_b).unwrap();
assert_eq!(
row_count(&engine_b, "\"persistent\".\"public\".\"primary_t\""),
3
);
assert_eq!(row_count(&engine_b, "\"src\".public.t"), 1);
}
#[test]
fn catalog_upsert_succeeds_while_attachment_is_live() {
use hyperdb_mcp::table_catalog;
let (engine, dir) = primary_workspace();
let source = build_source_hyper_file(&dir, "source.hyper", &[(1, "a")]);
let registry = AttachRegistry::new();
registry
.attach(
&engine,
AttachRequest {
alias: "src".into(),
source: AttachSource::LocalFile { path: source },
writable: false,
on_missing: OnMissing::Error,
},
)
.unwrap();
table_catalog::upsert_stub(
&engine,
"primary_t",
"copy_query",
Some(r#"{"mode":"create","target_table":"primary_t"}"#),
Some(2),
true,
)
.unwrap();
let entry = table_catalog::get(&engine, "primary_t").unwrap().unwrap();
assert_eq!(entry.load_tool.as_deref(), Some("copy_query"));
assert_eq!(entry.row_count, Some(2));
let params = entry.load_params.unwrap();
assert!(params.contains("\"mode\":\"create\""), "got {params}");
}