diff --git a/MIGRATING-0.3.md b/MIGRATING-0.3.md new file mode 100644 index 0000000..0dab583 --- /dev/null +++ b/MIGRATING-0.3.md @@ -0,0 +1,240 @@ +# Migrating to v0.3.0 + +This is the consolidated migration guide for the v0.3.0 bundle of breaking and additive changes. Each section corresponds to a bundle PR; the guide grows as each PR lands. The bundle ships as one major bump after the last PR merges. + +> Each bundle PR uses `chore:` Conventional Commit prefix to defer release-please from cutting an early version. After all PRs merge, a single `feat!:` commit with a `BREAKING CHANGE:` footer triggers v0.3.0. + +--- + +## #70 — Flatten the public `Error` enum + +The public `hyperdb_api::Error` type was redesigned into a flat enum per the [Microsoft Pragmatic Rust Guidelines](https://microsoft.github.io/rust-guidelines/) (M-ERRORS-CANONICAL-STRUCTS, M-ERRORS-AVOID-WRAPPING-AND-AS-DYN). Callers now match directly on variants instead of going through `kind() -> Option`. + +### What's gone + +| Removed | Status | +| ------------------------------------ | -------------------------------------------- | +| `Error::Client(client::Error)` | Variant deleted; `client::Error` is mapped to flat variants via internal `From` impl. | +| `Error::Other { message, source }` | Variant deleted; the `Box` cause channel is gone. | +| `Error::new(msg)` | Constructor deleted. Use a specific variant or `Error::internal(msg)` (see below). | +| `Error::with_cause(msg, e)` | Constructor deleted. For an `io::Error` cause use `Error::connection_with_io(msg, e)`; otherwise fold the cause into a message string. | +| `Error::kind() -> Option` | Method deleted. Match directly on the enum. | +| `pub use ... ErrorKind` from `hyperdb_api` | Re-export removed. The `ErrorKind` type is internal to `hyperdb-api-core` and not part of `hyperdb-api`'s public surface. | + +### What's new + +```rust +pub enum Error { + // Connection / transport + Connection { message: String, source: Option }, + Authentication(String), + Tls(String), + + // Server-side + Server { sqlstate: Option, message: String, detail: Option, hint: Option }, + Protocol(String), + + // I/O + Io(std::io::Error), + + // Lifecycle + Closed(String), + Timeout(String), + Cancelled(String), + + // Type / value + Conversion(String), + Config(String), + FeatureNotSupported(String), + + // Catalog / validation + InvalidName(String), + InvalidTableDefinition(String), + NotFound(String), + AlreadyExists(String), + + // Column / row mapping + Column { name: String, kind: ColumnErrorKind }, + ColumnIndexOutOfBounds { idx: usize, column_count: usize }, + + // Internal + Internal { message: String }, +} + +pub enum ColumnErrorKind { + Missing, + Null, + TypeMismatch { expected: String, actual: String }, +} +``` + +The enum is `#[non_exhaustive]`. Match arms must include a wildcard `_ =>` pattern. + +### Constructors + +Every variant has a snake_case constructor that takes `impl Into` for any string field. Use these instead of struct-expression or tuple-construction syntax — they accept `&str`, `String`, `format!(...)`, and any other `Into` source without `.to_string()` ceremony. + +```rust +// Struct variants +Error::internal("invariant violated: ..."); +Error::connection("failed to connect"); +Error::connection_with_io("read failed", io_err); // io_err: std::io::Error +Error::server(sqlstate, message, detail, hint); // all four args +Error::column("user_id", ColumnErrorKind::Missing); +Error::column_index_out_of_bounds(idx, column_count); + +// Tuple variants +Error::authentication("..."); +Error::tls("..."); +Error::protocol("..."); +Error::closed("..."); +Error::timeout("..."); +Error::cancelled("..."); +Error::conversion("..."); +Error::config("..."); +Error::feature_not_supported("..."); +Error::invalid_name("..."); +Error::invalid_table_definition("..."); +Error::not_found("..."); +Error::already_exists("..."); +``` + +Pattern-matching uses the PascalCase variant names (e.g. `Error::Conversion(msg)`); only construction switches to snake_case. Forward-compatibility for new struct-variant fields relies on going through these constructors — `#[non_exhaustive]` on individual struct variants is forbidden by Rust E0639. + +### Behavioral note: SQLSTATE on non-server errors + +`Error::sqlstate()` now returns `Some(...)` only for [`Error::Server`]. Previously it could return `Some` for any wrapped `client::Error` whose internal type carried a SQLSTATE code, including some `Cancelled`, `Closed`, and `Connection` paths that arrived from the server with codes like `57014` (`query_canceled`), `57P03` (`cannot_connect_now`), or `08*` connection-class codes. + +After v0.3.0 those SQLSTATE codes are folded into the variant's message string (still visible to humans via `Display`) but are not surfaced by `Error::sqlstate()`. If you branch on those codes, parse them out of the message string or open a follow-up issue requesting structured SQLSTATE on `Connection`/`Closed`/`Cancelled`/`Timeout` variants. + +### Migration recipes + +**Match on error kind** — before: +```rust +match err.kind() { + Some(ErrorKind::Connection) => retry(), + Some(ErrorKind::Authentication) => prompt_creds(), + _ => return Err(err), +} +``` + +after: +```rust +match err { + Error::Connection { .. } => retry(), + Error::Authentication(_) => prompt_creds(), + other => return Err(other), +} +``` + +**Wrap an `io::Error`** — before: +```rust +return Err(Error::with_cause("read failed", io_err)); +``` + +after: +```rust +return Err(Error::connection_with_io("read failed", io_err)); +// or, if the failure is a generic file-system I/O outside the connection +// path, propagate via ? on Error::Io(io_err) directly. +``` + +**Generic state assertion** — before: +```rust +return Err(Error::new("connection already closed")); +``` + +after: +```rust +return Err(Error::internal("connection already closed")); +// Or, if recoverable (closed mid-operation), Error::Closed("...".into()). +``` + +**Pattern-match on `Error::Other`** — before: +```rust +if let Error::Other { message, .. } = &err { /* … */ } +``` + +after — the variant is gone. Match on the specific new variant the call site produces. Most former `Other` constructions are now `Error::Conversion`, `Error::Internal`, `Error::Config`, `Error::FeatureNotSupported`, or `Error::InvalidName`/`InvalidTableDefinition` based on the original message. + +**Inspect the SQLSTATE of a server error** — `Error::sqlstate()` is preserved for backward-compatible inspection: +```rust +if err.sqlstate() == Some("23505") { /* duplicate-key path */ } +``` + +**Read SQLSTATE / detail / hint structurally** — new in v0.3.0: +```rust +if let Error::Server { sqlstate: Some(code), detail, hint, .. } = &err { + log::warn!("server error {code}: detail={detail:?} hint={hint:?}"); +} +``` + +### Notes for downstream crate authors + +- The `From for hyperdb_api::Error` impl is exhaustive over `client::ErrorKind`. Adding a kind to `client::Error` will break this build until a mapping is added. This is intended. +- `Error::Connection { source }` carries an `Option`. The wire-protocol layer in `hyperdb-api-core` does not preserve typed causes through its boundary, so `source` is `None` for errors that originated there. Direct callers in `hyperdb-api` who construct `Error::connection_with_io` *do* preserve the typed source. +- The `Error::Internal { .. }` variant is a deliberate catch-all for invariant violations. New code should reach for a domain variant first. + +--- + +## #70 (continued) — Ergonomic constructors across all workspace error types + +The same ergonomic-constructor pattern was applied to every error type in the workspace that user code might construct, so call sites no longer need `.to_string()` ceremony for string-literal arguments. + +### `hyperdb_api_salesforce::SalesforceAuthError` + +New constructors, all taking `impl Into`: + +```rust +SalesforceAuthError::config(message); +SalesforceAuthError::private_key(message); +SalesforceAuthError::jwt(message); +SalesforceAuthError::http(message); +SalesforceAuthError::authorization(error_code, error_description); // both impl Into +SalesforceAuthError::token_exchange(message); +SalesforceAuthError::token_parse(message); +SalesforceAuthError::io(message); +``` + +`SalesforceAuthError::TokenExpired` is a unit variant with no constructor. Pattern-matching keeps PascalCase (`if let Err(SalesforceAuthError::Authorization { .. }) = result`). 26 internal call sites were rewritten. + +### `hyperdb_bootstrap::Error` + +New constructors: + +```rust +Error::unsupported_platform(os, arch); // both impl Into +Error::unknown_platform_slug(slug); +Error::io(context, source: std::io::Error); +Error::http_status(url, status: u16); +Error::curl_failed(url, code: i32); +Error::checksum_mismatch(expected, actual); // both impl Into +``` + +`Error::HyperdNotInArchive` (unit) and `Error::ScrapeFailed(&'static str)` already required no ceremony. The `#[from]`-generated `Http`/`TomlParse`/`Zip` variants take typed sources — no constructor needed. 26 call sites rewritten. + +### `hyperdb_mcp::McpError` + +Already ergonomic — `McpError::new(code: ErrorCode, message: impl Into)` takes `impl Into`. One residual `.to_string()` ceremony site was cleaned up; no new constructors needed. + +### `hyperdb_api_core::client::Error` + +Already ergonomic — its existing convenience constructors (`Error::connection`, `Error::query`, `Error::feature_not_supported`, `Error::other`, etc.) all take `impl Into`. No changes required. + +### What this means for callers + +If you construct any of the workspace error types, drop the `.to_string()` / `.into()` from string-literal arguments: + +```rust +// Before +Error::Conversion("NULL id".to_string()) +SalesforceAuthError::Config("auth_mode is required".to_string()) +hyperdb_bootstrap::Error::Io { context: "remove tmp".to_string(), source: e } + +// After +hyperdb_api::Error::conversion("NULL id") +SalesforceAuthError::config("auth_mode is required") +hyperdb_bootstrap::Error::io("remove tmp", e) +``` + +`format!(...)` calls, owned `String` values, and `impl Display::to_string()` (where the source is not already `Into`) all still work unchanged through the constructors. diff --git a/hyperdb-api-core/tests/common/mod.rs b/hyperdb-api-core/tests/common/mod.rs index 4d70e2d..0c4f5c2 100644 --- a/hyperdb-api-core/tests/common/mod.rs +++ b/hyperdb-api-core/tests/common/mod.rs @@ -94,18 +94,18 @@ impl TestServer { // Connect without a database, then create the database via SQL let client = Client::connect(&config) - .map_err(|e| hyperdb_api::Error::new(format!("Failed to connect: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("Failed to connect: {e}")))?; // Drop database if it exists (from previous test run), then create it let db_path_escaped = database_path.to_string_lossy().replace('"', "\"\""); let _ = client.exec(&format!("DROP DATABASE IF EXISTS \"{db_path_escaped}\"")); client .exec(&format!("CREATE DATABASE \"{db_path_escaped}\"")) - .map_err(|e| hyperdb_api::Error::new(format!("Failed to create database: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("Failed to create database: {e}")))?; client .close() - .map_err(|e| hyperdb_api::Error::new(format!("Failed to close: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("Failed to close: {e}")))?; Ok(TestServer { hyper, diff --git a/hyperdb-api-salesforce/src/config.rs b/hyperdb-api-salesforce/src/config.rs index 09ba800..97cee53 100644 --- a/hyperdb-api-salesforce/src/config.rs +++ b/hyperdb-api-salesforce/src/config.rs @@ -90,7 +90,7 @@ impl AuthMode { private_key_pem: &str, ) -> SalesforceAuthResult { let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| { - SalesforceAuthError::PrivateKey(format!( + SalesforceAuthError::private_key(format!( "failed to parse private key (expected PKCS#8 PEM format): {e}" )) })?; @@ -216,15 +216,13 @@ impl SalesforceAuthConfig { // Validate the URL has a scheme and host if login_url.scheme() != "https" && login_url.scheme() != "http" { - return Err(SalesforceAuthError::Config( - "login_url must use http or https scheme".to_string(), + return Err(SalesforceAuthError::config( + "login_url must use http or https scheme", )); } if login_url.host().is_none() { - return Err(SalesforceAuthError::Config( - "login_url must have a host".to_string(), - )); + return Err(SalesforceAuthError::config("login_url must have a host")); } Ok(SalesforceAuthConfig { @@ -298,14 +296,13 @@ impl SalesforceAuthConfig { let auth_mode = self .auth_mode .as_ref() - .ok_or_else(|| SalesforceAuthError::Config("auth_mode is required".to_string()))?; + .ok_or_else(|| SalesforceAuthError::config("auth_mode is required"))?; match auth_mode { AuthMode::Password { .. } | AuthMode::RefreshToken { .. } => { if self.client_secret.is_none() { - return Err(SalesforceAuthError::Config( - "client_secret is required for Password and RefreshToken auth modes" - .to_string(), + return Err(SalesforceAuthError::config( + "client_secret is required for Password and RefreshToken auth modes", )); } } diff --git a/hyperdb-api-salesforce/src/error.rs b/hyperdb-api-salesforce/src/error.rs index cd1163a..1d6b835 100644 --- a/hyperdb-api-salesforce/src/error.rs +++ b/hyperdb-api-salesforce/src/error.rs @@ -45,6 +45,54 @@ pub enum SalesforceAuthError { Io(String), } +impl SalesforceAuthError { + /// Constructs a [`Self::Config`] error. + pub fn config(message: impl Into) -> Self { + SalesforceAuthError::Config(message.into()) + } + + /// Constructs a [`Self::PrivateKey`] error. + pub fn private_key(message: impl Into) -> Self { + SalesforceAuthError::PrivateKey(message.into()) + } + + /// Constructs a [`Self::Jwt`] error. + pub fn jwt(message: impl Into) -> Self { + SalesforceAuthError::Jwt(message.into()) + } + + /// Constructs a [`Self::Http`] error. + pub fn http(message: impl Into) -> Self { + SalesforceAuthError::Http(message.into()) + } + + /// Constructs a [`Self::Authorization`] error. + pub fn authorization( + error_code: impl Into, + error_description: impl Into, + ) -> Self { + SalesforceAuthError::Authorization { + error_code: error_code.into(), + error_description: error_description.into(), + } + } + + /// Constructs a [`Self::TokenExchange`] error. + pub fn token_exchange(message: impl Into) -> Self { + SalesforceAuthError::TokenExchange(message.into()) + } + + /// Constructs a [`Self::TokenParse`] error. + pub fn token_parse(message: impl Into) -> Self { + SalesforceAuthError::TokenParse(message.into()) + } + + /// Constructs a [`Self::Io`] error. + pub fn io(message: impl Into) -> Self { + SalesforceAuthError::Io(message.into()) + } +} + impl fmt::Display for SalesforceAuthError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/hyperdb-api-salesforce/src/jwt.rs b/hyperdb-api-salesforce/src/jwt.rs index 83a5618..63d0d3e 100644 --- a/hyperdb-api-salesforce/src/jwt.rs +++ b/hyperdb-api-salesforce/src/jwt.rs @@ -81,7 +81,7 @@ pub(crate) fn build_jwt_assertion( // Convert RSA private key to PEM for jsonwebtoken let private_key_pem = private_key_to_pem(private_key)?; let encoding_key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()) - .map_err(|e| SalesforceAuthError::Jwt(format!("failed to create encoding key: {e}")))?; + .map_err(|e| SalesforceAuthError::jwt(format!("failed to create encoding key: {e}")))?; // Create JWT header with RS256 algorithm let header = Header::new(Algorithm::RS256); @@ -105,7 +105,7 @@ fn private_key_to_pem(key: &RsaPrivateKey) -> SalesforceAuthResult { key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF) .map(|pem| pem.to_string()) - .map_err(|e| SalesforceAuthError::PrivateKey(format!("failed to encode private key: {e}"))) + .map_err(|e| SalesforceAuthError::private_key(format!("failed to encode private key: {e}"))) } #[cfg(test)] diff --git a/hyperdb-api-salesforce/src/provider.rs b/hyperdb-api-salesforce/src/provider.rs index 1e9028b..9b4da37 100644 --- a/hyperdb-api-salesforce/src/provider.rs +++ b/hyperdb-api-salesforce/src/provider.rs @@ -92,7 +92,7 @@ impl DataCloudTokenProvider { let http_client = HttpClient::builder() .timeout(Duration::from_secs(config.timeout_secs)) .build() - .map_err(|e| SalesforceAuthError::Http(format!("failed to create HTTP client: {e}")))?; + .map_err(|e| SalesforceAuthError::http(format!("failed to create HTTP client: {e}")))?; Ok(DataCloudTokenProvider { config, @@ -293,10 +293,11 @@ impl DataCloudTokenProvider { /// Fetches a fresh OAuth Access Token from Salesforce. async fn fetch_oauth_access_token(&self) -> SalesforceAuthResult { - let auth_mode = - self.config.auth_mode.as_ref().ok_or_else(|| { - SalesforceAuthError::Config("auth_mode not configured".to_string()) - })?; + let auth_mode = self + .config + .auth_mode + .as_ref() + .ok_or_else(|| SalesforceAuthError::config("auth_mode not configured"))?; let mut form_data = HashMap::new(); form_data.insert("client_id", self.config.client_id.clone()); @@ -345,7 +346,7 @@ impl DataCloudTokenProvider { } let token_url = self.config.login_url.join(OAUTH_TOKEN_PATH).map_err(|e| { - SalesforceAuthError::Config(format!("failed to build OAuth Access Token URL: {e}")) + SalesforceAuthError::config(format!("failed to build OAuth Access Token URL: {e}")) })?; debug!(url = %token_url, "Requesting OAuth Access Token"); @@ -357,7 +358,7 @@ impl DataCloudTokenProvider { let oauth_response: OAuthTokenResponse = serde_json::from_str(&response_text).map_err(|e| { - SalesforceAuthError::TokenParse(format!( + SalesforceAuthError::token_parse(format!( "failed to parse OAuth Access Token response: {e}" )) })?; @@ -405,7 +406,7 @@ impl DataCloudTokenProvider { .instance_url .join(DATA_CLOUD_TOKEN_PATH) .map_err(|e| { - SalesforceAuthError::Config(format!("failed to build DC JWT exchange URL: {e}")) + SalesforceAuthError::config(format!("failed to build DC JWT exchange URL: {e}")) })?; debug!(url = %exchange_url, "Exchanging OAuth Access Token for DC JWT"); @@ -417,7 +418,7 @@ impl DataCloudTokenProvider { let dc_response: DataCloudTokenResponse = serde_json::from_str(&response_text).map_err(|e| { - SalesforceAuthError::TokenParse(format!("failed to parse DC JWT response: {e}")) + SalesforceAuthError::token_parse(format!("failed to parse DC JWT response: {e}")) })?; debug!( @@ -481,19 +482,19 @@ impl DataCloudTokenProvider { .and_then(|v| v.as_str()) .unwrap_or(&body); - return Err(SalesforceAuthError::Authorization { - error_code: error_code.to_string(), - error_description: error_desc.to_string(), - }); + return Err(SalesforceAuthError::authorization( + error_code.to_string(), + error_desc.to_string(), + )); } - return Err(SalesforceAuthError::Http(format!( + return Err(SalesforceAuthError::http(format!( "HTTP {status} error: {body}" ))); } if response.status().is_server_error() { - last_error = Some(SalesforceAuthError::Http(format!( + last_error = Some(SalesforceAuthError::http(format!( "HTTP {} error", response.status() ))); @@ -508,9 +509,7 @@ impl DataCloudTokenProvider { } } - Err(last_error.unwrap_or_else(|| { - SalesforceAuthError::Http("request failed after retries".to_string()) - })) + Err(last_error.unwrap_or_else(|| SalesforceAuthError::http("request failed after retries"))) } } diff --git a/hyperdb-api-salesforce/src/token.rs b/hyperdb-api-salesforce/src/token.rs index d54d7da..180223c 100644 --- a/hyperdb-api-salesforce/src/token.rs +++ b/hyperdb-api-salesforce/src/token.rs @@ -61,14 +61,14 @@ impl OAuthTokenResponse { /// Checks if the response contains an error. pub fn check_error(&self) -> SalesforceAuthResult<()> { if let (Some(code), Some(desc)) = (&self.error, &self.error_description) { - return Err(SalesforceAuthError::Authorization { - error_code: code.clone(), - error_description: desc.clone(), - }); + return Err(SalesforceAuthError::authorization( + code.clone(), + desc.clone(), + )); } if self.access_token.is_empty() { - return Err(SalesforceAuthError::TokenParse( - "missing access_token in OAuth Access Token response".to_string(), + return Err(SalesforceAuthError::token_parse( + "missing access_token in OAuth Access Token response", )); } Ok(()) @@ -113,7 +113,7 @@ impl OAuthToken { response.check_error()?; let instance_url = Url::parse(&response.instance_url) - .map_err(|e| SalesforceAuthError::TokenParse(format!("invalid instance_url: {e}")))?; + .map_err(|e| SalesforceAuthError::token_parse(format!("invalid instance_url: {e}")))?; let now = Utc::now(); let expires_at = now + Duration::seconds(OAUTH_ACCESS_TOKEN_DEFAULT_LIFETIME_SECS); @@ -172,14 +172,14 @@ impl DataCloudTokenResponse { /// Checks if the response contains an error. pub fn check_error(&self) -> SalesforceAuthResult<()> { if let (Some(code), Some(desc)) = (&self.error, &self.error_description) { - return Err(SalesforceAuthError::Authorization { - error_code: code.clone(), - error_description: desc.clone(), - }); + return Err(SalesforceAuthError::authorization( + code.clone(), + desc.clone(), + )); } if self.access_token.is_empty() { - return Err(SalesforceAuthError::TokenParse( - "missing access_token in DC JWT response".to_string(), + return Err(SalesforceAuthError::token_parse( + "missing access_token in DC JWT response", )); } Ok(()) @@ -232,7 +232,7 @@ impl DataCloudToken { }; let tenant_url = Url::parse(&instance_url_with_scheme) - .map_err(|e| SalesforceAuthError::TokenParse(format!("invalid instance_url: {e}")))?; + .map_err(|e| SalesforceAuthError::token_parse(format!("invalid instance_url: {e}")))?; let token_type = response.token_type.unwrap_or_else(|| "Bearer".to_string()); @@ -361,8 +361,8 @@ impl DataCloudToken { pub fn tenant_id(&self) -> SalesforceAuthResult { let parts: Vec<&str> = self.token.split('.').collect(); if parts.len() != 3 { - return Err(SalesforceAuthError::TokenParse( - "invalid DC JWT format: expected 3 parts".to_string(), + return Err(SalesforceAuthError::token_parse( + "invalid DC JWT format: expected 3 parts", )); } @@ -375,9 +375,7 @@ impl DataCloudToken { .and_then(|v| v.as_str()) .map(std::string::ToString::to_string) .ok_or_else(|| { - SalesforceAuthError::TokenParse( - "missing audienceTenantId in DC JWT payload".to_string(), - ) + SalesforceAuthError::token_parse("missing audienceTenantId in DC JWT payload") }) } @@ -410,7 +408,7 @@ fn base64_url_decode(input: &str) -> SalesforceAuthResult> { URL_SAFE_NO_PAD .decode(padded.trim_end_matches('=')) - .map_err(|e| SalesforceAuthError::TokenParse(format!("base64 decode error: {e}"))) + .map_err(|e| SalesforceAuthError::token_parse(format!("base64 decode error: {e}"))) } #[cfg(test)] diff --git a/hyperdb-api/README.md b/hyperdb-api/README.md index bff87a2..731b1d3 100644 --- a/hyperdb-api/README.md +++ b/hyperdb-api/README.md @@ -108,7 +108,7 @@ async fn main() -> Result<()> { let pool = create_pool(config)?; // Get connections from the pool — returned automatically when dropped - let conn = pool.get().await.map_err(|e| hyperdb_api::Error::new(e.to_string()))?; + let conn = pool.get().await.map_err(|e| hyperdb_api::Error::internal(e.to_string()))?; conn.execute_command("SELECT 1").await?; Ok(()) diff --git a/hyperdb-api/benches/async_parallel_benchmark.rs b/hyperdb-api/benches/async_parallel_benchmark.rs index 9cbf91b..5a83c5b 100644 --- a/hyperdb-api/benches/async_parallel_benchmark.rs +++ b/hyperdb-api/benches/async_parallel_benchmark.rs @@ -69,7 +69,7 @@ async fn try_join_all_tasks(handles: Vec>>) for h in handles { let v = h .await - .map_err(|e| hyperdb_api::Error::new(format!("task join error: {e}")))??; + .map_err(|e| hyperdb_api::Error::internal(format!("task join error: {e}")))??; out.push(v); } Ok(out) @@ -118,7 +118,7 @@ fn main() -> Result<()> { let hyper = HyperProcess::new(None, Some(¶ms))?; let endpoint = hyper .endpoint() - .ok_or_else(|| hyperdb_api::Error::new("HyperProcess has no TCP endpoint"))? + .ok_or_else(|| hyperdb_api::Error::internal("HyperProcess has no TCP endpoint"))? .to_string(); // Multi-thread runtime so tokio can run N async inserts truly in parallel. @@ -443,7 +443,7 @@ async fn run_parallel_chunk_sender( conn.close() }) .await - .map_err(|e| hyperdb_api::Error::new(format!("bootstrap join error: {e}")))??; + .map_err(|e| hyperdb_api::Error::internal(format!("bootstrap join error: {e}")))??; } let wall_start = Instant::now(); diff --git a/hyperdb-api/benches/benchmark.rs b/hyperdb-api/benches/benchmark.rs index fcc94d7..d543ac0 100644 --- a/hyperdb-api/benches/benchmark.rs +++ b/hyperdb-api/benches/benchmark.rs @@ -429,7 +429,7 @@ fn benchmark_worker_thread( // Send chunk when it reaches the target size if chunk.row_count() >= rows_per_chunk || chunk.should_flush() { tx.send(chunk) - .map_err(|e| hyperdb_api::Error::new(format!("Channel send failed: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("Channel send failed: {e}")))?; chunk = InsertChunk::from_table_definition(table_def); } } @@ -437,7 +437,7 @@ fn benchmark_worker_thread( // Send any remaining rows if !chunk.is_empty() { tx.send(chunk) - .map_err(|e| hyperdb_api::Error::new(format!("Channel send failed: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("Channel send failed: {e}")))?; } Ok(()) @@ -450,10 +450,10 @@ fn validate_threaded_insert(connection: &Connection, expected_row_count: i64) -> // Check row count let actual_count: i64 = connection .execute_scalar_query::("SELECT COUNT(*) FROM measurements_threaded")? - .ok_or_else(|| hyperdb_api::Error::new("Failed to get row count"))?; + .ok_or_else(|| hyperdb_api::Error::internal("Failed to get row count"))?; if actual_count != expected_row_count { - return Err(hyperdb_api::Error::new(format!( + return Err(hyperdb_api::Error::internal(format!( "Row count mismatch! Expected {expected_row_count}, got {actual_count}" ))); } @@ -472,7 +472,7 @@ fn validate_threaded_insert(connection: &Connection, expected_row_count: i64) -> let _sum_id: i64 = row.get(2).unwrap_or(-1); if count != rows_per_sensor { - return Err(hyperdb_api::Error::new(format!( + return Err(hyperdb_api::Error::internal(format!( "Count mismatch for sensor_id={sensor_id}: expected {rows_per_sensor}, got {count}" ))); } @@ -658,10 +658,10 @@ fn validate_insert_persistence(connection: &Connection, expected_row_count: i64) // Check row count using scalar query let actual_count: i64 = connection .execute_scalar_query::("SELECT COUNT(*) FROM measurements")? - .ok_or_else(|| hyperdb_api::Error::new("Failed to get row count"))?; + .ok_or_else(|| hyperdb_api::Error::internal("Failed to get row count"))?; if actual_count != expected_row_count { - return Err(hyperdb_api::Error::new(format!( + return Err(hyperdb_api::Error::internal(format!( "Row count mismatch! Expected {expected_row_count}, got {actual_count}" ))); } @@ -689,7 +689,7 @@ fn validate_insert_persistence(connection: &Connection, expected_row_count: i64) || (value - expected_value).abs() > 0.001 || timestamp != expected_timestamp { - return Err(hyperdb_api::Error::new(format!( + return Err(hyperdb_api::Error::internal(format!( "Data mismatch at id={id}: got sensor_id={sensor_id}, value={value}, timestamp={timestamp}, expected sensor_id={expected_sensor_id}, value={expected_value}, timestamp={expected_timestamp}" ))); } @@ -719,7 +719,7 @@ fn validate_insert_persistence(connection: &Connection, expected_row_count: i64) || (value - expected_value).abs() > 0.001 || timestamp != expected_timestamp { - return Err(hyperdb_api::Error::new(format!( + return Err(hyperdb_api::Error::internal(format!( "Data mismatch at id={id}: got sensor_id={sensor_id}, value={value}, timestamp={timestamp}, expected sensor_id={expected_sensor_id}, value={expected_value}, timestamp={expected_timestamp}" ))); } @@ -739,7 +739,7 @@ fn validate_insert_persistence(connection: &Connection, expected_row_count: i64) let sum_id: i64 = row.get(2).unwrap_or(-1); if count != rows_per_sensor { - return Err(hyperdb_api::Error::new(format!( + return Err(hyperdb_api::Error::internal(format!( "Count mismatch for sensor_id={sensor_id}: expected {rows_per_sensor}, got {count}" ))); } @@ -803,10 +803,10 @@ struct TcpVsGrpcResult { fn bind_ephemeral_port() -> Result { let listener = std::net::TcpListener::bind("127.0.0.1:0") - .map_err(|e| hyperdb_api::Error::new(format!("failed to bind ephemeral port: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("failed to bind ephemeral port: {e}")))?; let port = listener .local_addr() - .map_err(|e| hyperdb_api::Error::new(format!("local_addr: {e}")))? + .map_err(|e| hyperdb_api::Error::internal(format!("local_addr: {e}")))? .port(); // Listener drops here, releasing the port for hyperd to claim. There is // a small race window between this function returning and hyperd @@ -861,7 +861,7 @@ fn run_tcp_vs_grpc_query_benchmark(row_count: i64, _db_path: &str) -> Result<()> let grpc_url = hyper .grpc_url() - .ok_or_else(|| hyperdb_api::Error::new("Both mode did not expose a gRPC URL"))?; + .ok_or_else(|| hyperdb_api::Error::internal("Both mode did not expose a gRPC URL"))?; println!(" TCP endpoint: {}", hyper.require_endpoint()?); println!(" gRPC URL: {grpc_url}"); println!( @@ -881,7 +881,7 @@ fn run_tcp_vs_grpc_query_benchmark(row_count: i64, _db_path: &str) -> Result<()> // point it at a scratch temp file since the query itself references // no tables. let tmp = tempfile::tempdir() - .map_err(|e| hyperdb_api::Error::new(format!("failed to create tempdir: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("failed to create tempdir: {e}")))?; let scratch_db = tmp.path().join("tcp_vs_grpc_scratch.hyper"); let tcp_result = { diff --git a/hyperdb-api/benches/benchmark_suite.rs b/hyperdb-api/benches/benchmark_suite.rs index 56c0870..9a149f5 100644 --- a/hyperdb-api/benches/benchmark_suite.rs +++ b/hyperdb-api/benches/benchmark_suite.rs @@ -111,7 +111,7 @@ fn main() -> Result<()> { let hyper = HyperProcess::new(None, Some(¶ms))?; let endpoint = hyper .endpoint() - .ok_or_else(|| hyperdb_api::Error::new("HyperProcess has no endpoint"))? + .ok_or_else(|| hyperdb_api::Error::internal("HyperProcess has no endpoint"))? .to_string(); let rt = tokio::runtime::Builder::new_multi_thread() @@ -430,13 +430,13 @@ fn chunk_sender_worker( chunk.end_row()?; if chunk.row_count() >= ROWS_PER_BATCH || chunk.should_flush() { tx.send(chunk) - .map_err(|e| hyperdb_api::Error::new(format!("mpsc send: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("mpsc send: {e}")))?; chunk = InsertChunk::from_table_definition(table_def); } } if !chunk.is_empty() { tx.send(chunk) - .map_err(|e| hyperdb_api::Error::new(format!("mpsc send: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("mpsc send: {e}")))?; } Ok(()) } @@ -585,7 +585,7 @@ async fn async_arrow_parallel( for t in tasks { total += t .await - .map_err(|e| hyperdb_api::Error::new(format!("join: {e}")))??; + .map_err(|e| hyperdb_api::Error::internal(format!("join: {e}")))??; } let elapsed = start.elapsed().as_secs_f64(); @@ -624,7 +624,7 @@ async fn async_blocking_chunksender_parallel( conn.close() }) .await - .map_err(|e| hyperdb_api::Error::new(format!("bootstrap join: {e}")))??; + .map_err(|e| hyperdb_api::Error::internal(format!("bootstrap join: {e}")))??; } let per_worker = rows / num_workers as i64; @@ -668,7 +668,7 @@ async fn async_blocking_chunksender_parallel( for t in tasks { total += t .await - .map_err(|e| hyperdb_api::Error::new(format!("blocking join: {e}")))??; + .map_err(|e| hyperdb_api::Error::internal(format!("blocking join: {e}")))??; } let elapsed = start.elapsed().as_secs_f64(); @@ -842,7 +842,7 @@ async fn async_query_parallel( for t in tasks { total += t .await - .map_err(|e| hyperdb_api::Error::new(format!("query join: {e}")))??; + .map_err(|e| hyperdb_api::Error::internal(format!("query join: {e}")))??; } let elapsed = start.elapsed().as_secs_f64(); diff --git a/hyperdb-api/examples/additional_examples/connection_pool.rs b/hyperdb-api/examples/additional_examples/connection_pool.rs index 0bf8385..7f0083d 100644 --- a/hyperdb-api/examples/additional_examples/connection_pool.rs +++ b/hyperdb-api/examples/additional_examples/connection_pool.rs @@ -47,7 +47,7 @@ async fn main() -> Result<()> { let conn = pool .get() .await - .map_err(|e| hyperdb_api::Error::new(e.to_string()))?; + .map_err(|e| hyperdb_api::Error::internal(e.to_string()))?; conn.execute_command( "CREATE TABLE counters ( id INT NOT NULL, diff --git a/hyperdb-api/examples/additional_examples/grpc_query.rs b/hyperdb-api/examples/additional_examples/grpc_query.rs index 5faf59e..096b7d3 100644 --- a/hyperdb-api/examples/additional_examples/grpc_query.rs +++ b/hyperdb-api/examples/additional_examples/grpc_query.rs @@ -433,7 +433,7 @@ mod grpc_example { let arrow_data = conn.execute_query_to_arrow(query)?; let reader = StreamReader::try_new(Cursor::new(&arrow_data), None) - .map_err(|e| hyperdb_api::Error::new(format!("Arrow error: {e}")))?; + .map_err(|e| hyperdb_api::Error::conversion(format!("Arrow error: {e}")))?; println!("Schema:"); for field in reader.schema().fields() { @@ -447,8 +447,8 @@ mod grpc_example { println!("{}", "-".repeat(35)); for batch_result in reader { - let batch = - batch_result.map_err(|e| hyperdb_api::Error::new(format!("Arrow error: {e}")))?; + let batch = batch_result + .map_err(|e| hyperdb_api::Error::conversion(format!("Arrow error: {e}")))?; // Get columns by index (matching the SELECT order) let id_col = batch @@ -501,7 +501,7 @@ mod grpc_example { let arrow_data = conn.execute_query_to_arrow(&query)?; let reader = StreamReader::try_new(Cursor::new(&arrow_data), None) - .map_err(|e| hyperdb_api::Error::new(format!("Arrow error: {e}")))?; + .map_err(|e| hyperdb_api::Error::conversion(format!("Arrow error: {e}")))?; // Aggregate statistics let mut total_rows: u64 = 0; @@ -513,8 +513,8 @@ mod grpc_example { let mut category_sums: [f64; 5] = [0.0; 5]; for batch_result in reader { - let batch = - batch_result.map_err(|e| hyperdb_api::Error::new(format!("Arrow error: {e}")))?; + let batch = batch_result + .map_err(|e| hyperdb_api::Error::conversion(format!("Arrow error: {e}")))?; let category_col = batch .column(1) @@ -598,7 +598,7 @@ mod grpc_example { let arrow_data = conn.execute_query_to_arrow(&agg_query)?; let reader = StreamReader::try_new(Cursor::new(&arrow_data), None) - .map_err(|e| hyperdb_api::Error::new(format!("Arrow error: {e}")))?; + .map_err(|e| hyperdb_api::Error::conversion(format!("Arrow error: {e}")))?; println!("Server-side aggregation results:"); println!( @@ -608,8 +608,8 @@ mod grpc_example { println!("{}", "-".repeat(75)); for batch_result in reader { - let batch = - batch_result.map_err(|e| hyperdb_api::Error::new(format!("Arrow error: {e}")))?; + let batch = batch_result + .map_err(|e| hyperdb_api::Error::conversion(format!("Arrow error: {e}")))?; let cat_col = batch .column(0) diff --git a/hyperdb-api/examples/additional_examples/threaded_inserter.rs b/hyperdb-api/examples/additional_examples/threaded_inserter.rs index 374d722..201693f 100644 --- a/hyperdb-api/examples/additional_examples/threaded_inserter.rs +++ b/hyperdb-api/examples/additional_examples/threaded_inserter.rs @@ -322,7 +322,7 @@ fn worker_thread( // Send chunk when it reaches the target size if chunk.row_count() >= rows_per_chunk || chunk.should_flush() { tx.send(chunk) - .map_err(|e| hyperdb_api::Error::new(format!("Channel send failed: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("Channel send failed: {e}")))?; chunks_created += 1; chunk = InsertChunk::from_table_definition(table_def); } @@ -331,7 +331,7 @@ fn worker_thread( // Send any remaining rows if !chunk.is_empty() { tx.send(chunk) - .map_err(|e| hyperdb_api::Error::new(format!("Channel send failed: {e}")))?; + .map_err(|e| hyperdb_api::Error::internal(format!("Channel send failed: {e}")))?; chunks_created += 1; } diff --git a/hyperdb-api/examples/async_parity_smoke.rs b/hyperdb-api/examples/async_parity_smoke.rs index 91300a0..07e5e14 100644 --- a/hyperdb-api/examples/async_parity_smoke.rs +++ b/hyperdb-api/examples/async_parity_smoke.rs @@ -29,7 +29,7 @@ impl FromRow for Order { Ok(Order { id: row .get::(0) - .ok_or_else(|| hyperdb_api::Error::new("NULL id"))?, + .ok_or_else(|| hyperdb_api::Error::conversion("NULL id"))?, customer: row.get::(1).unwrap_or_default(), total: row.get::(2).unwrap_or(0.0), }) diff --git a/hyperdb-api/examples/grpc_truncation_probe.rs b/hyperdb-api/examples/grpc_truncation_probe.rs index e0ea2e3..4950c67 100644 --- a/hyperdb-api/examples/grpc_truncation_probe.rs +++ b/hyperdb-api/examples/grpc_truncation_probe.rs @@ -53,7 +53,7 @@ fn main() -> hyperdb_api::Result<()> { let hyper = HyperProcess::new(None, Some(¶ms))?; let grpc_url = hyper .grpc_url() - .ok_or_else(|| hyperdb_api::Error::new("no gRPC URL"))? + .ok_or_else(|| hyperdb_api::Error::internal("no gRPC URL"))? .clone(); let query = format!( diff --git a/hyperdb-api/src/arrow_inserter.rs b/hyperdb-api/src/arrow_inserter.rs index 64d8824..8c55f43 100644 --- a/hyperdb-api/src/arrow_inserter.rs +++ b/hyperdb-api/src/arrow_inserter.rs @@ -162,22 +162,24 @@ impl<'conn> ArrowInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] with message + /// - Returns [`Error::InvalidTableDefinition`] with message /// `"Table definition must have at least one column"` if `table_def` /// has no columns. - /// - Returns [`Error::Other`] if `connection` is using gRPC transport + /// - Returns [`Error::FeatureNotSupported`] if `connection` is using gRPC transport /// (COPY is TCP-only). pub fn new(connection: &'conn Connection, table_def: &TableDefinition) -> Result { let column_count = table_def.column_count(); if column_count == 0 { - return Err(Error::new("Table definition must have at least one column")); + return Err(Error::invalid_table_definition( + "Table definition must have at least one column", + )); } // Fail fast: verify the connection supports COPY (TCP only). // The actual COPY session is started lazily on the first data write // to avoid locking the connection into COPY mode prematurely. if connection.tcp_client().is_none() { - return Err(Error::new( + return Err(Error::feature_not_supported( "ArrowInserter requires a TCP connection. \ gRPC connections do not support COPY operations.", )); @@ -299,7 +301,7 @@ impl<'conn> ArrowInserter<'conn> { } if self.insert_mode == Some(InsertMode::BatchIpc) { - return Err(Error::new( + return Err(Error::internal( "Cannot mix insert_data() with insert_batch(). \ Use either raw IPC methods (insert_data/insert_record_batches) \ or RecordBatch methods (insert_batch), not both.", @@ -307,7 +309,7 @@ impl<'conn> ArrowInserter<'conn> { } if self.schema_sent { - return Err(Error::new( + return Err(Error::internal( "Arrow schema was already sent. Use insert_record_batches() for subsequent chunks without schema, \ or use insert_data() only once with the complete Arrow IPC stream.", )); @@ -384,7 +386,7 @@ impl<'conn> ArrowInserter<'conn> { } if self.insert_mode == Some(InsertMode::BatchIpc) { - return Err(Error::new( + return Err(Error::internal( "Cannot mix insert_record_batches() with insert_batch(). \ Use either raw IPC methods (insert_data/insert_record_batches) \ or RecordBatch methods (insert_batch), not both.", @@ -392,7 +394,7 @@ impl<'conn> ArrowInserter<'conn> { } if !self.schema_sent { - return Err(Error::new( + return Err(Error::internal( "No Arrow schema has been sent yet. Call insert_data() first with a complete \ Arrow IPC stream that includes the schema.", )); @@ -434,12 +436,12 @@ impl<'conn> ArrowInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if a previous `insert_batch` call already + /// - Returns [`Error::Internal`] if a previous `insert_batch` call already /// locked the inserter into `RecordBatch` IPC mode — raw IPC and /// `RecordBatch` paths cannot be mixed. - /// - Returns [`Error::Other`] / [`Error::Client`] if the lazy COPY + /// - Returns [`Error::FeatureNotSupported`] / [`Error::Server`] if the lazy COPY /// session fails to open. - /// - Returns [`Error::Client`] / [`Error::Io`] if the server rejects + /// - Returns [`Error::Server`] / [`Error::Io`] if the server rejects /// the data or the socket write fails. pub fn insert_raw(&mut self, data: &[u8]) -> Result<()> { if data.is_empty() { @@ -447,7 +449,7 @@ impl<'conn> ArrowInserter<'conn> { } if self.insert_mode == Some(InsertMode::BatchIpc) { - return Err(Error::new( + return Err(Error::internal( "Cannot mix insert_raw() with insert_batch(). \ Use either raw IPC methods (insert_data/insert_record_batches/insert_raw) \ or RecordBatch methods (insert_batch), not both.", @@ -501,9 +503,9 @@ impl<'conn> ArrowInserter<'conn> { // On error, `self` is dropped, and the Drop impl cancels the COPY // writer, so the connection is always left in a clean state. if let Some(ipc) = self.batch_ipc_writer.take() { - let buf = ipc - .into_inner() - .map_err(|e| Error::new(format!("Failed to finalize Arrow IPC stream: {e}")))?; + let buf = ipc.into_inner().map_err(|e| { + Error::conversion(format!("Failed to finalize Arrow IPC stream: {e}")) + })?; if !buf.is_empty() { if let Some(ref mut writer) = self.writer { writer.send_direct(&buf)?; @@ -609,15 +611,15 @@ impl<'conn> ArrowInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if a previous raw-IPC call locked this + /// - Returns [`Error::Internal`] if a previous raw-IPC call locked this /// inserter into the other mode — raw IPC and `RecordBatch` paths /// cannot be mixed. - /// - Returns [`Error::Other`] / [`Error::Client`] if the lazy COPY + /// - Returns [`Error::FeatureNotSupported`] / [`Error::Server`] if the lazy COPY /// session cannot be opened. - /// - Returns [`Error::Other`] wrapping the underlying Arrow IPC + /// - Returns [`Error::Conversion`] wrapping the underlying Arrow IPC /// writer error if the schema or batch cannot be serialized (e.g. /// dictionary misalignment, encoding failure). - /// - Returns [`Error::Client`] / [`Error::Io`] if the server rejects + /// - Returns [`Error::Server`] / [`Error::Io`] if the server rejects /// the data or the socket write fails. /// /// # Panics @@ -628,7 +630,7 @@ impl<'conn> ArrowInserter<'conn> { /// `Some`, so the unwrap is unreachable. pub fn insert_batch(&mut self, batch: &arrow::record_batch::RecordBatch) -> Result<()> { if self.insert_mode == Some(InsertMode::RawIpc) { - return Err(Error::new( + return Err(Error::internal( "Cannot mix insert_batch() with raw IPC methods. \ Use either RecordBatch methods (insert_batch) \ or raw IPC methods (insert_data/insert_record_batches/insert_raw), not both.", @@ -640,8 +642,9 @@ impl<'conn> ArrowInserter<'conn> { // Create the IPC StreamWriter on first use — this writes the schema message if self.batch_ipc_writer.is_none() { - let ipc_writer = StreamWriter::try_new(Vec::new(), &batch.schema()) - .map_err(|e| Error::new(format!("Failed to create Arrow IPC writer: {e}")))?; + let ipc_writer = StreamWriter::try_new(Vec::new(), &batch.schema()).map_err(|e| { + Error::conversion(format!("Failed to create Arrow IPC writer: {e}")) + })?; self.batch_ipc_writer = Some(ipc_writer); // Drain the schema bytes that StreamWriter wrote during construction @@ -654,7 +657,7 @@ impl<'conn> ArrowInserter<'conn> { .as_mut() .expect("IPC writer must exist") .write(batch) - .map_err(|e| Error::new(format!("Failed to write Arrow batch: {e}")))?; + .map_err(|e| Error::conversion(format!("Failed to write Arrow batch: {e}")))?; // Drain the batch bytes and send them immediately self.drain_ipc_buffer()?; @@ -741,7 +744,9 @@ impl<'conn> ArrowInserter<'conn> { fn ensure_writer(&mut self) -> Result<()> { if self.writer.is_none() { let client = self.connection.tcp_client().ok_or_else(|| { - crate::Error::new("ArrowInserter requires a TCP connection. gRPC connections do not support COPY operations.") + crate::Error::feature_not_supported( + "ArrowInserter requires a TCP connection. gRPC connections do not support COPY operations.", + ) })?; let columns: Vec<&str> = self .columns diff --git a/hyperdb-api/src/arrow_reader.rs b/hyperdb-api/src/arrow_reader.rs index b8c0b66..4053c9d 100644 --- a/hyperdb-api/src/arrow_reader.rs +++ b/hyperdb-api/src/arrow_reader.rs @@ -103,15 +103,17 @@ impl<'conn> ArrowReader<'conn> { /// /// # Errors /// - /// - Returns [`crate::Error::Other`] if the connection is using gRPC + /// - Returns [`crate::Error::FeatureNotSupported`] if the connection is using gRPC /// transport (ArrowReader wraps `COPY TO STDOUT`, which is TCP-only). - /// - Returns [`crate::Error::Client`] if the server rejects the + /// - Returns [`crate::Error::Server`] if the server rejects the /// `COPY () TO STDOUT WITH (format arrowstream)` statement. /// - Returns [`crate::Error::Io`] on transport-level I/O failures. pub fn query_to_arrow(&self, select_query: &str) -> Result> { let copy_query = format!("COPY ({select_query}) TO STDOUT WITH (format arrowstream)"); let client = self.connection.tcp_client().ok_or_else(|| { - crate::Error::new("ArrowReader requires a TCP connection. Use Connection::execute_query_to_arrow() for gRPC.") + crate::Error::feature_not_supported( + "ArrowReader requires a TCP connection. Use Connection::execute_query_to_arrow() for gRPC." + ) })?; Ok(client.copy_out(©_query)?) } diff --git a/hyperdb-api/src/arrow_result.rs b/hyperdb-api/src/arrow_result.rs index c762269..f390055 100644 --- a/hyperdb-api/src/arrow_result.rs +++ b/hyperdb-api/src/arrow_result.rs @@ -386,7 +386,7 @@ pub trait ChunkSource: Send { /// # Errors /// /// Implementations return whatever transport error the underlying - /// source produces (typically [`Error::Client`] from a gRPC stream or + /// source produces (typically [`Error::Server`] from a gRPC stream or /// [`Error::Io`] on network failures). fn next_chunk(&mut self) -> Result>; } @@ -473,7 +473,7 @@ impl ArrowRowset { /// /// # Errors /// - /// Returns [`Error::Other`] wrapping an Arrow IPC decode error if + /// Returns [`Error::Conversion`] wrapping an Arrow IPC decode error if /// `bytes` is not a valid Arrow IPC stream (or concatenation thereof). pub fn from_bytes(bytes: Bytes) -> Result { if bytes.is_empty() { @@ -489,7 +489,7 @@ impl ArrowRowset { /// /// # Errors /// - /// Returns [`Error::Other`] wrapping an Arrow IPC decode error if + /// Returns [`Error::Conversion`] wrapping an Arrow IPC decode error if /// `buf` is not a valid Arrow IPC stream. pub fn from_buffer(buf: Buffer) -> Result { if buf.is_empty() { @@ -514,7 +514,7 @@ impl ArrowRowset { /// /// # Errors /// - /// Returns [`Error::Other`] wrapping an Arrow IPC decode error if any + /// Returns [`Error::Conversion`] wrapping an Arrow IPC decode error if any /// chunk cannot be parsed as a self-contained IPC stream. pub fn from_chunks(chunks: I) -> Result where @@ -559,7 +559,7 @@ impl ArrowRowset { /// /// - Returns the transport error from `source.next_chunk()` when /// priming the decoder with the first chunk. - /// - Returns [`Error::Other`] wrapping an Arrow IPC decode error if + /// - Returns [`Error::Conversion`] wrapping an Arrow IPC decode error if /// that first chunk is not a valid Arrow IPC stream prefix. pub fn from_stream(source: Box) -> Result { let mut rowset = ArrowRowset { @@ -627,7 +627,7 @@ impl ArrowRowset { /// /// # Errors /// - /// Returns [`Error::Other`] wrapping an Arrow IPC decode error if + /// Returns [`Error::Conversion`] wrapping an Arrow IPC decode error if /// `data` is not a valid Arrow IPC stream. pub fn from_ipc_slice(data: &[u8]) -> Result { if data.is_empty() { @@ -677,7 +677,7 @@ impl ArrowRowset { /// /// For streaming rowsets: /// - Returns the transport error from `source.next_chunk()`. - /// - Returns [`Error::Other`] wrapping an Arrow IPC decode error if a + /// - Returns [`Error::Conversion`] wrapping an Arrow IPC decode error if a /// chunk contains malformed stream bytes. /// /// Buffered rowsets never error — they walk a pre-decoded vector. @@ -839,7 +839,7 @@ pub(crate) fn arrow_type_to_sql_type(dt: &DataType) -> SqlType { /// /// # Errors /// -/// Returns [`Error::Other`] wrapping an Arrow IPC decode error if `bytes` +/// Returns [`Error::Conversion`] wrapping an Arrow IPC decode error if `bytes` /// is not a valid Arrow IPC stream (or concatenation thereof). pub fn parse_arrow_ipc(bytes: Bytes) -> Result> { if bytes.is_empty() { @@ -935,7 +935,9 @@ fn drive_streaming_decoder( *decoder = StreamDecoder::new(); continue; } - return Err(Error::new(format!("Failed to parse Arrow IPC data: {e}"))); + return Err(Error::conversion(format!( + "Failed to parse Arrow IPC data: {e}" + ))); } } } @@ -1011,7 +1013,7 @@ fn decode_possibly_concatenated_streams( // drive_streaming_decoder consumed nothing; we're either // done (if buf is empty) or stuck (malformed input). if !buf.is_empty() { - return Err(Error::new( + return Err(Error::conversion( "Failed to parse Arrow IPC data: decoder made no progress", )); } diff --git a/hyperdb-api/src/async_arrow_inserter.rs b/hyperdb-api/src/async_arrow_inserter.rs index 009b7b0..49711d4 100644 --- a/hyperdb-api/src/async_arrow_inserter.rs +++ b/hyperdb-api/src/async_arrow_inserter.rs @@ -90,20 +90,22 @@ impl<'conn> AsyncArrowInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] with message + /// - Returns [`Error::InvalidTableDefinition`] with message /// `"Table definition must have at least one column"` if `table_def` /// has no columns. - /// - Returns [`Error::Other`] if `connection` is using gRPC transport + /// - Returns [`Error::FeatureNotSupported`] if `connection` is using gRPC transport /// (COPY is TCP-only). pub fn new(connection: &'conn AsyncConnection, table_def: &TableDefinition) -> Result { let column_count = table_def.column_count(); if column_count == 0 { - return Err(Error::new("Table definition must have at least one column")); + return Err(Error::invalid_table_definition( + "Table definition must have at least one column", + )); } // Fail fast: verify the connection supports COPY (TCP only) if connection.async_tcp_client().is_none() { - return Err(Error::new( + return Err(Error::feature_not_supported( "AsyncArrowInserter requires a TCP connection. \ gRPC connections do not support COPY operations.", )); @@ -144,12 +146,12 @@ impl<'conn> AsyncArrowInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if a schema was already sent (call + /// - Returns [`Error::Internal`] if a schema was already sent (call /// [`insert_record_batches`](Self::insert_record_batches) for /// subsequent chunks instead). - /// - Returns [`Error::Other`] / [`Error::Client`] if the lazy COPY + /// - Returns [`Error::FeatureNotSupported`] / [`Error::Server`] if the lazy COPY /// session cannot be opened. - /// - Returns [`Error::Client`] / [`Error::Io`] if the server rejects + /// - Returns [`Error::Server`] / [`Error::Io`] if the server rejects /// the data or the socket write fails. pub async fn insert_data(&mut self, arrow_ipc_data: &[u8]) -> Result<()> { if arrow_ipc_data.is_empty() { @@ -157,7 +159,7 @@ impl<'conn> AsyncArrowInserter<'conn> { } if self.schema_sent { - return Err(Error::new( + return Err(Error::internal( "Arrow schema was already sent. Use insert_record_batches() for subsequent chunks without schema, \ or use insert_data() only once with the complete Arrow IPC stream.", )); @@ -194,9 +196,9 @@ impl<'conn> AsyncArrowInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if no schema has been sent yet (call + /// - Returns [`Error::Internal`] if no schema has been sent yet (call /// [`insert_data`](Self::insert_data) first). - /// - Returns [`Error::Client`] / [`Error::Io`] if the server rejects + /// - Returns [`Error::Server`] / [`Error::Io`] if the server rejects /// the data or the socket write fails. pub async fn insert_record_batches(&mut self, arrow_batch_data: &[u8]) -> Result<()> { if arrow_batch_data.is_empty() { @@ -204,7 +206,7 @@ impl<'conn> AsyncArrowInserter<'conn> { } if !self.schema_sent { - return Err(Error::new( + return Err(Error::internal( "No Arrow schema has been sent yet. Call insert_data() first with a complete \ Arrow IPC stream that includes the schema.", )); @@ -239,9 +241,9 @@ impl<'conn> AsyncArrowInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] / [`Error::Client`] if the lazy COPY + /// - Returns [`Error::FeatureNotSupported`] / [`Error::Server`] if the lazy COPY /// session cannot be opened. - /// - Returns [`Error::Client`] / [`Error::Io`] if the server rejects + /// - Returns [`Error::Server`] / [`Error::Io`] if the server rejects /// the data or the socket write fails. pub async fn insert_raw(&mut self, data: &[u8]) -> Result<()> { if data.is_empty() { @@ -270,7 +272,7 @@ impl<'conn> AsyncArrowInserter<'conn> { /// /// # Errors /// - /// Returns [`Error::Client`] or [`Error::Io`] if the `CommandComplete` + /// Returns [`Error::Server`] or [`Error::Io`] if the `CommandComplete` /// round-trip fails (server rejected some buffered batch, or the socket /// closed mid-flush). If no data was ever written, returns `Ok(0)`. pub async fn execute(mut self) -> Result { @@ -328,7 +330,9 @@ impl<'conn> AsyncArrowInserter<'conn> { async fn ensure_writer(&mut self) -> Result<()> { if self.writer.is_none() { let client = self.connection.async_tcp_client().ok_or_else(|| { - crate::Error::new("AsyncArrowInserter requires a TCP connection. gRPC connections do not support COPY operations.") + crate::Error::feature_not_supported( + "AsyncArrowInserter requires a TCP connection. gRPC connections do not support COPY operations.", + ) })?; let columns: Vec<&str> = self .columns @@ -433,18 +437,20 @@ impl AsyncArrowInserterOwned { /// /// # Errors /// - /// - Returns [`Error::Other`] with message + /// - Returns [`Error::InvalidTableDefinition`] with message /// `"Table definition must have at least one column"` if `table_def` /// has no columns. - /// - Returns [`Error::Other`] if `connection` is using gRPC transport. + /// - Returns [`Error::FeatureNotSupported`] if `connection` is using gRPC transport. pub fn new(connection: Arc, table_def: &TableDefinition) -> Result { let column_count = table_def.column_count(); if column_count == 0 { - return Err(Error::new("Table definition must have at least one column")); + return Err(Error::invalid_table_definition( + "Table definition must have at least one column", + )); } if connection.async_tcp_client().is_none() { - return Err(Error::new( + return Err(Error::feature_not_supported( "AsyncArrowInserterOwned requires a TCP connection. \ gRPC connections do not support COPY operations.", )); @@ -481,17 +487,17 @@ impl AsyncArrowInserterOwned { /// /// # Errors /// - /// - Returns [`Error::Other`] if a schema was already sent. - /// - Returns [`Error::Other`] / [`Error::Client`] if the lazy COPY + /// - Returns [`Error::Internal`] if a schema was already sent. + /// - Returns [`Error::FeatureNotSupported`] / [`Error::Server`] if the lazy COPY /// session cannot be opened. - /// - Returns [`Error::Client`] / [`Error::Io`] if the server rejects + /// - Returns [`Error::Server`] / [`Error::Io`] if the server rejects /// the data or the socket write fails. pub async fn insert_data(&mut self, arrow_ipc_data: &[u8]) -> Result<()> { if arrow_ipc_data.is_empty() { return Ok(()); } if self.schema_sent { - return Err(Error::new( + return Err(Error::internal( "Arrow schema was already sent. Use insert_record_batches() for subsequent chunks.", )); } @@ -513,15 +519,15 @@ impl AsyncArrowInserterOwned { /// /// # Errors /// - /// - Returns [`Error::Other`] if no schema has been sent yet. - /// - Returns [`Error::Client`] / [`Error::Io`] if the server rejects + /// - Returns [`Error::Internal`] if no schema has been sent yet. + /// - Returns [`Error::Server`] / [`Error::Io`] if the server rejects /// the data or the socket write fails. pub async fn insert_record_batches(&mut self, arrow_batch_data: &[u8]) -> Result<()> { if arrow_batch_data.is_empty() { return Ok(()); } if !self.schema_sent { - return Err(Error::new( + return Err(Error::internal( "No Arrow schema has been sent yet. Call insert_data() first.", )); } @@ -541,9 +547,9 @@ impl AsyncArrowInserterOwned { /// /// # Errors /// - /// - Returns [`Error::Other`] / [`Error::Client`] if the lazy COPY + /// - Returns [`Error::FeatureNotSupported`] / [`Error::Server`] if the lazy COPY /// session cannot be opened. - /// - Returns [`Error::Client`] / [`Error::Io`] if the server rejects + /// - Returns [`Error::Server`] / [`Error::Io`] if the server rejects /// the data or the socket write fails. pub async fn insert_raw(&mut self, data: &[u8]) -> Result<()> { if data.is_empty() { @@ -565,10 +571,10 @@ impl AsyncArrowInserterOwned { /// /// # Errors /// - /// - Returns [`Error::Other`] with message + /// - Returns [`Error::Internal`] with message /// `"No data was inserted before execute()"` if no COPY session was /// ever opened. - /// - Returns [`Error::Client`] / [`Error::Io`] if the `CommandComplete` + /// - Returns [`Error::Server`] / [`Error::Io`] if the `CommandComplete` /// round-trip fails. pub async fn execute(mut self) -> Result { let elapsed = self.start_time.elapsed(); @@ -582,7 +588,7 @@ impl AsyncArrowInserterOwned { let writer = self .writer .take() - .ok_or_else(|| Error::new("No data was inserted before execute()"))?; + .ok_or_else(|| Error::internal("No data was inserted before execute()"))?; writer.finish().await.map_err(Into::into) } @@ -614,7 +620,7 @@ impl AsyncArrowInserterOwned { async fn ensure_writer(&mut self) -> Result<()> { if self.writer.is_none() { let client: &AsyncClient = self.connection.async_tcp_client().ok_or_else(|| { - Error::new( + Error::feature_not_supported( "AsyncArrowInserterOwned requires a TCP connection. \ gRPC connections do not support COPY operations.", ) diff --git a/hyperdb-api/src/async_connection.rs b/hyperdb-api/src/async_connection.rs index 0844d57..7227894 100644 --- a/hyperdb-api/src/async_connection.rs +++ b/hyperdb-api/src/async_connection.rs @@ -73,9 +73,9 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Io`] / [`Error::Client`] if the handshake with + /// - Returns [`Error::Io`] / [`Error::Connection`] if the handshake with /// the server fails. - /// - Returns [`Error::Client`] if the `CreateMode` SQL (`CREATE` + /// - Returns [`Error::Server`] if the `CreateMode` SQL (`CREATE` /// / `DROP` / `ATTACH`) is rejected by the server. pub async fn connect(endpoint: &str, database: &str, mode: CreateMode) -> Result { let transport = AsyncTransport::connect(endpoint, Some(database)).await?; @@ -98,9 +98,9 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Client`] if authentication is rejected. + /// - Returns [`Error::Authentication`] if authentication is rejected. /// - Returns [`Error::Io`] if the endpoint cannot be reached. - /// - Returns [`Error::Client`] if the `CreateMode` SQL is rejected. + /// - Returns [`Error::Server`] if the `CreateMode` SQL is rejected. pub async fn connect_with_auth( endpoint: &str, database: &str, @@ -129,7 +129,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Io`] or [`Error::Client`] if the TCP handshake + /// Returns [`Error::Io`] or [`Error::Connection`] if the TCP handshake /// with `endpoint` fails. pub async fn without_database(endpoint: &str) -> Result { let transport = AsyncTransport::connect_tcp(endpoint).await?; @@ -249,9 +249,9 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transports that do not yet + /// - Returns [`Error::FeatureNotSupported`] on gRPC transports that do not yet /// support write operations. - /// - Returns [`Error::Client`] if the SQL fails to parse or execute. + /// - Returns [`Error::Server`] if the SQL fails to parse or execute. /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn execute_command(&self, sql: &str) -> Result { let token = self.stats_before_query(sql); @@ -267,7 +267,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns an [`Error::Other`] wrapping the first failing statement's + /// Returns an [`Error::Internal`] wrapping the first failing statement's /// error; the wrapping message includes the statement's ordinal and /// an 80-character SQL preview. pub async fn execute_batch(&self, statements: &[&str]) -> Result { @@ -276,15 +276,13 @@ impl AsyncConnection { if !stmt.trim().is_empty() { total += self.execute_command(stmt).await.map_err(|e| { let preview: String = stmt.chars().take(80).collect(); - Error::with_cause( - format!( - "execute_batch failed at statement {} of {}: {}", - i + 1, - statements.len(), - preview, - ), + Error::internal(format!( + "execute_batch failed at statement {} of {}: {}: {}", + i + 1, + statements.len(), + preview, e, - ) + )) })?; } } @@ -303,7 +301,7 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Client`] if the SQL is rejected by the server. + /// - Returns [`Error::Server`] if the SQL is rejected by the server. /// - Returns [`Error::Io`] on transport-level I/O failures while /// opening the stream. pub async fn execute_query(&self, query: &str) -> Result> { @@ -319,7 +317,7 @@ impl AsyncConnection { /// /// - Returns the error from [`execute_query`](Self::execute_query) if /// the query fails. - /// - Returns [`Error::Other`] with message `"Query returned no rows"` if + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` if /// the query produced zero rows. pub async fn fetch_one>(&self, query: Q) -> Result { self.execute_query(query.as_ref()) @@ -380,9 +378,9 @@ impl AsyncConnection { /// # Errors /// /// - Returns the error from [`execute_query`](Self::execute_query). - /// - Returns [`Error::Other`] with message `"Query returned no rows"` if + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` if /// the query is empty. - /// - Returns [`Error::Other`] with message `"Scalar query returned NULL"` + /// - Returns [`Error::Conversion`] with message `"Scalar query returned NULL"` /// if the first cell is SQL `NULL`. pub async fn fetch_scalar(&self, query: Q) -> Result where @@ -433,7 +431,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Propagates any [`Error::Client`] from the transport when the query + /// Propagates any [`Error::Server`] from the transport when the query /// fails or the server cannot produce Arrow IPC output. pub async fn execute_query_to_arrow(&self, sql: &str) -> Result { self.transport.execute_query_to_arrow(sql).await @@ -453,8 +451,8 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Client`] if the query fails. - /// - Returns [`Error::Other`] if the Arrow IPC payload cannot be + /// - Returns [`Error::Server`] if the query fails. + /// - Returns [`Error::Conversion`] if the Arrow IPC payload cannot be /// decoded into record batches. pub async fn execute_query_to_batches( &self, @@ -476,9 +474,9 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transports (prepared statements + /// - Returns [`Error::FeatureNotSupported`] on gRPC transports (prepared statements /// are TCP-only). - /// - Returns [`Error::Client`] if the server rejects the statement at + /// - Returns [`Error::Server`] if the server rejects the statement at /// `Parse`, `Bind`, or `Execute` time. /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn query_params( @@ -492,7 +490,7 @@ impl AsyncConnection { let client = match &self.transport { AsyncTransport::Tcp(tcp) => &tcp.client, AsyncTransport::Grpc(_) => { - return Err(Error::new( + return Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )); } @@ -511,8 +509,8 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transports. - /// - Returns [`Error::Client`] if the server rejects the statement at + /// - Returns [`Error::FeatureNotSupported`] on gRPC transports. + /// - Returns [`Error::Server`] if the server rejects the statement at /// `Parse`, `Bind`, or `Execute` time. /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn command_params( @@ -523,7 +521,7 @@ impl AsyncConnection { let client = match &self.transport { AsyncTransport::Tcp(tcp) => &tcp.client, AsyncTransport::Grpc(_) => { - return Err(Error::new( + return Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )); } @@ -542,7 +540,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects + /// Returns [`Error::Server`] if the server rejects /// `CREATE DATABASE IF NOT EXISTS` (e.g. the path is not writable). pub async fn create_database(&self, path: &str) -> Result<()> { let sql = format!("CREATE DATABASE IF NOT EXISTS {}", escape_sql_path(path)); @@ -554,7 +552,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects + /// Returns [`Error::Server`] if the server rejects /// `DROP DATABASE IF EXISTS` (e.g. the database is still attached). pub async fn drop_database(&self, path: &str) -> Result<()> { let sql = format!("DROP DATABASE IF EXISTS {}", escape_sql_path(path)); @@ -566,7 +564,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects the + /// Returns [`Error::Server`] if the server rejects the /// `ATTACH DATABASE` statement (file missing, permission denied, /// alias conflict). pub async fn attach_database(&self, path: &str, alias: Option<&str>) -> Result<()> { @@ -587,7 +585,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the alias is not attached or the + /// Returns [`Error::Server`] if the alias is not attached or the /// server cannot flush pending updates. pub async fn detach_database(&self, alias: &str) -> Result<()> { let sql = format!("DETACH DATABASE {}", escape_sql_path(alias)); @@ -599,7 +597,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects + /// Returns [`Error::Server`] if the server rejects /// `DETACH ALL DATABASES`. pub async fn detach_all_databases(&self) -> Result<()> { self.execute_command("DETACH ALL DATABASES").await?; @@ -610,7 +608,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects the + /// Returns [`Error::Server`] if the server rejects the /// `COPY DATABASE` statement — e.g. the source is not attached or the /// destination path is not writable. pub async fn copy_database(&self, source: &str, destination: &str) -> Result<()> { @@ -629,7 +627,7 @@ impl AsyncConnection { /// /// - Returns an error if `schema_name` cannot be converted to a /// [`SchemaName`](crate::SchemaName). - /// - Returns [`Error::Client`] if the server rejects + /// - Returns [`Error::Server`] if the server rejects /// `CREATE SCHEMA IF NOT EXISTS`. pub async fn create_schema(&self, schema_name: T) -> Result<()> where @@ -648,7 +646,7 @@ impl AsyncConnection { /// /// - Returns an error if `schema` cannot be converted to a /// [`SchemaName`](crate::SchemaName). - /// - Returns [`Error::Client`] if the catalog lookup query fails. + /// - Returns [`Error::Server`] if the catalog lookup query fails. pub async fn has_schema(&self, schema: T) -> Result where T: TryInto, @@ -674,7 +672,7 @@ impl AsyncConnection { /// /// - Returns an error if `table_name` cannot be converted to a /// [`TableName`](crate::TableName). - /// - Returns [`Error::Client`] if the catalog lookup query fails. + /// - Returns [`Error::Server`] if the catalog lookup query fails. pub async fn has_table(&self, table_name: T) -> Result where T: TryInto, @@ -702,7 +700,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `UNLOAD DATABASE` + /// Returns [`Error::Server`] if the server rejects `UNLOAD DATABASE` /// (e.g. the database is in use by another session). pub async fn unload_database(&self) -> Result<()> { self.execute_command("UNLOAD DATABASE").await?; @@ -713,7 +711,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `UNLOAD RELEASE`, + /// Returns [`Error::Server`] if the server rejects `UNLOAD RELEASE`, /// most commonly because multiple databases are attached to the same /// session. pub async fn unload_release(&self) -> Result<()> { @@ -729,7 +727,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if `EXPLAIN ` fails to parse or plan. + /// Returns [`Error::Server`] if `EXPLAIN ` fails to parse or plan. pub async fn explain(&self, query: &str) -> Result { let sql = format!("EXPLAIN {query}"); let rows = self.fetch_all(&sql).await?; @@ -741,7 +739,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if `EXPLAIN ANALYZE ` fails — this + /// Returns [`Error::Server`] if `EXPLAIN ANALYZE ` fails — this /// includes any runtime error raised by actually executing `query`. pub async fn explain_analyze(&self, query: &str) -> Result { let sql = format!("EXPLAIN ANALYZE {query}"); @@ -766,7 +764,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] or [`Error::Io`] if the `SELECT 1` + /// Returns [`Error::Server`] or [`Error::Io`] if the `SELECT 1` /// round-trip fails — i.e. the connection is no longer usable. pub async fn ping(&self) -> Result<()> { self.execute_command("SELECT 1").await?; @@ -818,9 +816,9 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transports — cancellation is not + /// - Returns [`Error::FeatureNotSupported`] on gRPC transports — cancellation is not /// yet implemented for gRPC. - /// - Returns [`Error::Client`] or [`Error::Io`] if the cancel-request + /// - Returns [`Error::Connection`] or [`Error::Io`] if the cancel-request /// connection to the server fails. pub async fn cancel(&self) -> Result<()> { self.transport.cancel().await @@ -830,9 +828,9 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Other`] wrapping the transport close failure if + /// - Returns [`Error::Internal`] wrapping the transport close failure if /// the client cannot be shut down cleanly. - /// - Returns [`Error::Other`] wrapping the detach failure if the + /// - Returns [`Error::Internal`] wrapping the detach failure if the /// attached database could not be detached but the transport close /// itself succeeded. pub async fn close(self) -> Result<()> { @@ -851,14 +849,15 @@ impl AsyncConnection { let close_result = self.transport.close().await; if let Err(e) = close_result { - return Err(Error::with_cause("Failed to close async connection", e)); + return Err(Error::internal(format!( + "Failed to close async connection: {e}" + ))); } if let Some(e) = detach_err { - return Err(Error::with_cause( - "Failed to detach database during close", - e, - )); + return Err(Error::internal(format!( + "Failed to detach database during close: {e}" + ))); } Ok(()) @@ -899,9 +898,9 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transports (prepared statements + /// - Returns [`Error::FeatureNotSupported`] on gRPC transports (prepared statements /// are TCP-only). - /// - Returns [`Error::Client`] if the server rejects the `Parse` + /// - Returns [`Error::Server`] if the server rejects the `Parse` /// message (SQL syntax error, unknown OID). /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn prepare_typed( @@ -912,7 +911,7 @@ impl AsyncConnection { let client = match &self.transport { AsyncTransport::Tcp(tcp) => &tcp.client, AsyncTransport::Grpc(_) => { - return Err(Error::new( + return Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )); } @@ -943,8 +942,8 @@ impl AsyncConnection { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transports. - /// - Returns [`Error::Client`] if the server rejects the `Parse` + /// - Returns [`Error::FeatureNotSupported`] on gRPC transports. + /// - Returns [`Error::Server`] if the server rejects the `Parse` /// message. /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn prepare_typed_arc( @@ -955,7 +954,7 @@ impl AsyncConnection { let client = match &self.transport { AsyncTransport::Tcp(tcp) => &tcp.client, AsyncTransport::Grpc(_) => { - return Err(Error::new( + return Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )); } @@ -1019,7 +1018,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `BEGIN TRANSACTION` + /// Returns [`Error::Server`] if the server rejects `BEGIN TRANSACTION` /// (e.g. a transaction is already open on this session). pub async fn begin_transaction(&self) -> Result<()> { self.execute_command("BEGIN TRANSACTION").await?; @@ -1030,7 +1029,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `COMMIT` (e.g. no + /// Returns [`Error::Server`] if the server rejects `COMMIT` (e.g. no /// transaction is currently open). pub async fn commit(&self) -> Result<()> { self.execute_command("COMMIT").await?; @@ -1041,7 +1040,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `ROLLBACK` (e.g. no + /// Returns [`Error::Server`] if the server rejects `ROLLBACK` (e.g. no /// transaction is currently open). pub async fn rollback(&self) -> Result<()> { self.execute_command("ROLLBACK").await?; @@ -1052,7 +1051,7 @@ impl AsyncConnection { /// /// # Errors /// - /// Returns [`Error::Client`] if the internal `BEGIN` issued by + /// Returns [`Error::Server`] if the internal `BEGIN` issued by /// [`AsyncTransaction::new`](crate::AsyncTransaction) fails. pub async fn transaction(&mut self) -> Result> { crate::AsyncTransaction::new(self).await diff --git a/hyperdb-api/src/async_connection_builder.rs b/hyperdb-api/src/async_connection_builder.rs index 0ae08e5..a6d17d9 100644 --- a/hyperdb-api/src/async_connection_builder.rs +++ b/hyperdb-api/src/async_connection_builder.rs @@ -173,11 +173,11 @@ impl AsyncConnectionBuilder { /// /// # Errors /// - /// - Returns [`Error::Io`] or [`Error::Client`] if the transport + /// - Returns [`Error::Io`] or [`Error::Connection`] if the transport /// handshake fails (TCP refused, TLS rejected, named-pipe not /// found, gRPC channel setup failure). - /// - Returns [`Error::Client`] if authentication is rejected. - /// - Returns [`Error::Client`] if the `CreateMode` SQL is rejected + /// - Returns [`Error::Authentication`] if authentication is rejected. + /// - Returns [`Error::Server`] if the `CreateMode` SQL is rejected /// for a builder that configured a database path. pub async fn build(self) -> Result { let transport_type = detect_transport_type(&self.endpoint); @@ -196,7 +196,7 @@ impl AsyncConnectionBuilder { let mut config: Config = self .endpoint .parse() - .map_err(|e| Error::new(format!("invalid endpoint: {e}")))?; + .map_err(|e| Error::config(format!("invalid endpoint: {e}")))?; if let Some(user) = &self.user { config = config.with_user(user); @@ -235,11 +235,11 @@ impl AsyncConnectionBuilder { let socket_path = if self.endpoint.starts_with("tab.domain://") { let endpoint = ConnectionEndpoint::parse(&self.endpoint) - .map_err(|e| Error::new(format!("invalid Unix socket endpoint: {e}")))?; + .map_err(|e| Error::config(format!("invalid Unix socket endpoint: {e}")))?; match endpoint { ConnectionEndpoint::DomainSocket { directory, name } => directory.join(&name), ConnectionEndpoint::Tcp { .. } => { - return Err(Error::new("expected Unix domain socket endpoint")) + return Err(Error::config("expected Unix domain socket endpoint")) } } } else { @@ -278,12 +278,12 @@ impl AsyncConnectionBuilder { let pipe_path = if self.endpoint.starts_with("tab.pipe://") { let endpoint = ConnectionEndpoint::parse(&self.endpoint) - .map_err(|e| Error::new(format!("invalid named pipe endpoint: {e}")))?; + .map_err(|e| Error::config(format!("invalid named pipe endpoint: {e}")))?; match endpoint { ConnectionEndpoint::NamedPipe { host, name } => { format!(r"\\{host}\pipe\{name}") } - _ => return Err(Error::new("expected named pipe endpoint")), + _ => return Err(Error::config("expected named pipe endpoint")), } } else { self.endpoint.clone() @@ -317,7 +317,7 @@ impl AsyncConnectionBuilder { /// Build a gRPC connection (async). async fn build_grpc(self) -> Result { if self.create_mode != CreateMode::DoNotCreate { - return Err(Error::new( + return Err(Error::feature_not_supported( "gRPC transport is read-only. Use CreateMode::DoNotCreate for gRPC connections.", )); } diff --git a/hyperdb-api/src/async_inserter.rs b/hyperdb-api/src/async_inserter.rs index a5c4571..0632e5c 100644 --- a/hyperdb-api/src/async_inserter.rs +++ b/hyperdb-api/src/async_inserter.rs @@ -88,16 +88,16 @@ impl<'conn> AsyncInserter<'conn> { /// # Errors /// /// - Returns [`Error::InvalidTableDefinition`] if `table_def` has zero columns. - /// - Returns [`Error::Other`] if `connection` is using gRPC transport + /// - Returns [`Error::FeatureNotSupported`] if `connection` is using gRPC transport /// (COPY is TCP-only). pub fn new(connection: &'conn AsyncConnection, table_def: &TableDefinition) -> Result { if table_def.column_count() == 0 { - return Err(Error::InvalidTableDefinition( - "Table definition must have at least one column".into(), + return Err(Error::invalid_table_definition( + "Table definition must have at least one column", )); } if connection.async_tcp_client().is_none() { - return Err(Error::new( + return Err(Error::feature_not_supported( "AsyncInserter requires a TCP connection. \ gRPC connections do not support COPY operations.", )); @@ -196,7 +196,7 @@ impl<'conn> AsyncInserter<'conn> { .columns .get(column_index) .map_or("", |c| c.name.as_str()); - Error::new(format!( + Error::conversion(format!( "Cannot determine numeric precision for column '{col_name}' at index {column_index}. \ Ensure the column is defined with explicit SqlType including precision." )) @@ -204,7 +204,7 @@ impl<'conn> AsyncInserter<'conn> { if precision <= Numeric::SMALL_NUMERIC_MAX_PRECISION { let unscaled = value.unscaled_value(); let narrowed = i64::try_from(unscaled).map_err(|_| { - Error::new(format!( + Error::conversion(format!( "Numeric value {unscaled} is out of range for i64 storage (precision {precision})" )) })?; @@ -219,9 +219,9 @@ impl<'conn> AsyncInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if the column count for the row doesn't match + /// - Returns [`Error::InvalidTableDefinition`] if the column count for the row doesn't match /// the table definition. - /// - Returns [`Error::Client`] / [`Error::Io`] on transport failures + /// - Returns [`Error::Server`] / [`Error::Io`] on transport failures /// during the auto-flush. pub async fn end_row(&mut self) -> Result<()> { self.chunk.end_row()?; @@ -238,7 +238,7 @@ impl<'conn> AsyncInserter<'conn> { // Lazily start the COPY session on first flush. if self.writer.is_none() { let client = self.connection.async_tcp_client().ok_or_else(|| { - Error::new( + Error::feature_not_supported( "AsyncInserter requires a TCP connection. \ gRPC connections do not support COPY operations.", ) @@ -271,12 +271,14 @@ impl<'conn> AsyncInserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if there's an incomplete row (partial column). - /// - Returns [`Error::Client`] / [`Error::Io`] if the COPY session or + /// - Returns [`Error::InvalidTableDefinition`] if there's an incomplete row (partial column). + /// - Returns [`Error::Server`] / [`Error::Io`] if the COPY session or /// transport fails. pub async fn execute(&mut self) -> Result { if self.chunk.column_index() != 0 { - return Err(Error::new("Incomplete row at execute time")); + return Err(Error::invalid_table_definition( + "Incomplete row at execute time", + )); } if self.row_count == 0 { return Ok(0); diff --git a/hyperdb-api/src/async_prepared.rs b/hyperdb-api/src/async_prepared.rs index 33dbd33..cbcc29b 100644 --- a/hyperdb-api/src/async_prepared.rs +++ b/hyperdb-api/src/async_prepared.rs @@ -80,8 +80,8 @@ impl<'conn> AsyncPreparedStatement<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transport. - /// - Returns [`Error::Client`] if the server rejects `Bind` or + /// - Returns [`Error::FeatureNotSupported`] on gRPC transport. + /// - Returns [`Error::Server`] if the server rejects `Bind` or /// `Execute`. /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn query(&self, params: &[&dyn ToSqlParam]) -> Result> { @@ -102,8 +102,8 @@ impl<'conn> AsyncPreparedStatement<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transport. - /// - Returns [`Error::Client`] if the server rejects `Bind` or + /// - Returns [`Error::FeatureNotSupported`] on gRPC transport. + /// - Returns [`Error::Server`] if the server rejects `Bind` or /// `Execute`. /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn execute(&self, params: &[&dyn ToSqlParam]) -> Result { @@ -119,7 +119,7 @@ impl<'conn> AsyncPreparedStatement<'conn> { /// # Errors /// /// - Returns the error from [`query`](Self::query). - /// - Returns [`Error::Other`] with message `"Query returned no rows"` + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` /// if the result is empty. pub async fn fetch_one(&self, params: &[&dyn ToSqlParam]) -> Result { self.query(params).await?.require_first_row().await @@ -150,9 +150,9 @@ impl<'conn> AsyncPreparedStatement<'conn> { /// # Errors /// /// - Returns the error from [`query`](Self::query). - /// - Returns [`Error::Other`] with message `"Query returned no rows"` + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` /// if the result is empty. - /// - Returns [`Error::Other`] with message `"Scalar query returned NULL"` + /// - Returns [`Error::Conversion`] with message `"Scalar query returned NULL"` /// if the first cell is SQL `NULL`. pub async fn fetch_scalar(&self, params: &[&dyn ToSqlParam]) -> Result { self.query(params).await?.require_scalar().await @@ -248,8 +248,8 @@ impl AsyncPreparedStatementOwned { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transport. - /// - Returns [`Error::Client`] if the server rejects `Bind` or + /// - Returns [`Error::FeatureNotSupported`] on gRPC transport. + /// - Returns [`Error::Server`] if the server rejects `Bind` or /// `Execute`, or raises a runtime error while streaming. /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn fetch_all(&self, params: &[&dyn ToSqlParam]) -> Result> { @@ -270,8 +270,8 @@ impl AsyncPreparedStatementOwned { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transport. - /// - Returns [`Error::Client`] if the server rejects `Bind` or + /// - Returns [`Error::FeatureNotSupported`] on gRPC transport. + /// - Returns [`Error::Server`] if the server rejects `Bind` or /// `Execute`. /// - Returns [`Error::Io`] on transport-level I/O failures. pub async fn execute(&self, params: &[&dyn ToSqlParam]) -> Result { @@ -287,14 +287,14 @@ impl AsyncPreparedStatementOwned { /// # Errors /// /// - Returns the error from [`fetch_all`](Self::fetch_all). - /// - Returns [`Error::Other`] with message `"Query returned no rows"` + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` /// if the result is empty. pub async fn fetch_one(&self, params: &[&dyn ToSqlParam]) -> Result { self.fetch_all(params) .await? .into_iter() .next() - .ok_or_else(|| crate::error::Error::new("Query returned no rows")) + .ok_or_else(|| crate::error::Error::conversion("Query returned no rows")) } /// Fetches at most one row; `None` on empty. @@ -312,12 +312,12 @@ impl AsyncPreparedStatementOwned { /// # Errors /// /// - Returns the error from [`fetch_one`](Self::fetch_one). - /// - Returns [`Error::Other`] with message `"Scalar query returned NULL"` + /// - Returns [`Error::Conversion`] with message `"Scalar query returned NULL"` /// if the first cell is SQL `NULL`. pub async fn fetch_scalar(&self, params: &[&dyn ToSqlParam]) -> Result { let row = self.fetch_one(params).await?; row.get::(0) - .ok_or_else(|| crate::error::Error::new("Scalar query returned NULL")) + .ok_or_else(|| crate::error::Error::conversion("Scalar query returned NULL")) } /// Fetches the first column of the first row as `Option`. @@ -351,7 +351,7 @@ fn async_tcp_client_arc( ) -> Result<&hyperdb_api_core::client::AsyncClient> { match connection.transport() { AsyncTransport::Tcp(tcp) => Ok(&tcp.client), - AsyncTransport::Grpc(_) => Err(Error::new( + AsyncTransport::Grpc(_) => Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )), } @@ -362,7 +362,7 @@ pub(crate) fn async_tcp_client( ) -> Result<&hyperdb_api_core::client::AsyncClient> { match connection.transport() { AsyncTransport::Tcp(tcp) => Ok(&tcp.client), - AsyncTransport::Grpc(_) => Err(Error::new( + AsyncTransport::Grpc(_) => Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )), } diff --git a/hyperdb-api/src/async_result.rs b/hyperdb-api/src/async_result.rs index 90ebd8d..f1b1e50 100644 --- a/hyperdb-api/src/async_result.rs +++ b/hyperdb-api/src/async_result.rs @@ -177,10 +177,10 @@ impl<'conn> AsyncRowset<'conn> { /// /// # Errors /// - /// - Returns [`crate::Error::Client`] if the server sends an `ErrorResponse` + /// - Returns [`crate::Error::Server`] if the server sends an `ErrorResponse` /// while streaming the result set. /// - Returns [`crate::Error::Io`] on transport-level I/O failures. - /// - Returns [`crate::Error::Other`] if an Arrow IPC chunk cannot be decoded. + /// - Returns [`crate::Error::Conversion`] if an Arrow IPC chunk cannot be decoded. pub async fn next_chunk(&mut self) -> Result>> { enum TransportChunk { Tcp(Vec), @@ -279,12 +279,12 @@ impl<'conn> AsyncRowset<'conn> { /// # Errors /// /// - Returns the error from [`first_row`](Self::first_row). - /// - Returns [`crate::Error::Other`] with message `"Query returned no rows"` + /// - Returns [`crate::Error::Conversion`] with message `"Query returned no rows"` /// if the result set is empty. pub async fn require_first_row(self) -> Result { self.first_row() .await? - .ok_or_else(|| crate::error::Error::new("Query returned no rows")) + .ok_or_else(|| crate::error::Error::conversion("Query returned no rows")) } /// Returns the first column of the first row as `Option`, or an @@ -305,11 +305,11 @@ impl<'conn> AsyncRowset<'conn> { /// # Errors /// /// - Returns the error from [`scalar`](Self::scalar). - /// - Returns [`crate::Error::Other`] with message `"Scalar query returned NULL"` + /// - Returns [`crate::Error::Conversion`] with message `"Scalar query returned NULL"` /// if the single cell is SQL `NULL`. pub async fn require_scalar(self) -> Result { self.scalar() .await? - .ok_or_else(|| crate::error::Error::new("Scalar query returned NULL")) + .ok_or_else(|| crate::error::Error::conversion("Scalar query returned NULL")) } } diff --git a/hyperdb-api/src/async_transport.rs b/hyperdb-api/src/async_transport.rs index 3976ae6..978a9a6 100644 --- a/hyperdb-api/src/async_transport.rs +++ b/hyperdb-api/src/async_transport.rs @@ -136,11 +136,11 @@ impl AsyncTransport { let socket_path = if endpoint.starts_with("tab.domain://") { let parsed = ConnectionEndpoint::parse(endpoint) - .map_err(|e| Error::new(format!("invalid Unix socket endpoint: {e}")))?; + .map_err(|e| Error::config(format!("invalid Unix socket endpoint: {e}")))?; match parsed { ConnectionEndpoint::DomainSocket { directory, name } => directory.join(&name), ConnectionEndpoint::Tcp { .. } => { - return Err(Error::new("expected Unix domain socket endpoint")); + return Err(Error::config("expected Unix domain socket endpoint")); } } } else { @@ -163,12 +163,12 @@ impl AsyncTransport { let pipe_path = if endpoint.starts_with("tab.pipe://") { let parsed = ConnectionEndpoint::parse(endpoint) - .map_err(|e| Error::new(format!("invalid named pipe endpoint: {e}")))?; + .map_err(|e| Error::config(format!("invalid named pipe endpoint: {e}")))?; match parsed { ConnectionEndpoint::NamedPipe { host, name } => { format!(r"\\{host}\pipe\{name}") } - _ => return Err(Error::new("expected named pipe endpoint")), + _ => return Err(Error::config("expected named pipe endpoint")), } } else { endpoint.to_string() @@ -184,7 +184,7 @@ impl AsyncTransport { pub(crate) async fn execute_command(&self, sql: &str) -> Result { match self { AsyncTransport::Tcp(tcp) => Ok(tcp.client.exec(sql).await?), - AsyncTransport::Grpc(_) => Err(Error::new( + AsyncTransport::Grpc(_) => Err(Error::feature_not_supported( "gRPC transport is read-only. Write operations (INSERT, UPDATE, DELETE, DDL) \ are not yet supported over gRPC. Use a TCP connection for write operations.", )), @@ -256,7 +256,9 @@ impl AsyncTransport { } AsyncTransport::Grpc(_) => { // gRPC cancellation would be handled differently - Err(Error::new("Query cancellation not supported over gRPC")) + Err(Error::feature_not_supported( + "Query cancellation not supported over gRPC", + )) } } } diff --git a/hyperdb-api/src/catalog.rs b/hyperdb-api/src/catalog.rs index 38ce7d6..ac6adbd 100644 --- a/hyperdb-api/src/catalog.rs +++ b/hyperdb-api/src/catalog.rs @@ -144,7 +144,7 @@ impl<'conn> Catalog<'conn> { /// /// - Returns an error if `schema_name` cannot be converted to a /// [`SchemaName`](crate::SchemaName). - /// - Returns [`Error::Client`] if the server rejects + /// - Returns [`Error::Server`] if the server rejects /// `CREATE SCHEMA IF NOT EXISTS`. pub fn create_schema(&self, schema_name: T) -> Result<()> where @@ -262,7 +262,7 @@ impl<'conn> Catalog<'conn> { /// /// - Returns an error if `schema` cannot be converted to a /// [`SchemaName`](crate::SchemaName). - /// - Returns [`Error::Client`] if the `pg_catalog.pg_namespace` lookup + /// - Returns [`Error::Server`] if the `pg_catalog.pg_namespace` lookup /// query fails. pub fn has_schema(&self, schema: T) -> Result where @@ -304,7 +304,7 @@ impl<'conn> Catalog<'conn> { /// /// - Returns an error if `table_name` cannot be converted to a /// [`TableName`](crate::TableName). - /// - Returns [`Error::Client`] if the `pg_catalog.pg_tables` lookup + /// - Returns [`Error::Server`] if the `pg_catalog.pg_tables` lookup /// query fails. pub fn has_table(&self, table_name: T) -> Result where @@ -444,7 +444,7 @@ impl<'conn> Catalog<'conn> { } if !found_columns { - return Err(Error::NotFound(format!("Table {schema}.{table}"))); + return Err(Error::not_found(format!("Table {schema}.{table}"))); } Ok(table_def) @@ -478,7 +478,7 @@ impl<'conn> Catalog<'conn> { /// /// - Returns [`Error::InvalidTableDefinition`] if `table_def` cannot be /// rendered as valid SQL (zero columns, bad identifiers). - /// - Returns [`Error::Client`] if the server rejects + /// - Returns [`Error::Server`] if the server rejects /// `CREATE TABLE IF NOT EXISTS`. pub fn create_table_if_not_exists(&self, table_def: &TableDefinition) -> Result<()> { let sql = table_def.to_create_sql(false)?; @@ -515,7 +515,7 @@ impl<'conn> Catalog<'conn> { /// /// - Returns an error if `table_name` cannot be converted to a /// [`TableName`](crate::TableName). - /// - Returns [`Error::Client`] if the server rejects + /// - Returns [`Error::Server`] if the server rejects /// `DROP TABLE IF EXISTS`. pub fn drop_table_if_exists(&self, table_name: T) -> Result<()> where @@ -559,7 +559,7 @@ impl<'conn> Catalog<'conn> { /// /// - Returns an error if `schema_name` cannot be converted to a /// [`SchemaName`](crate::SchemaName). - /// - Returns [`Error::Client`] if the server rejects + /// - Returns [`Error::Server`] if the server rejects /// `DROP SCHEMA IF EXISTS` — typically because `cascade` was `false` /// and the schema is not empty. pub fn drop_schema_if_exists(&self, schema_name: T, cascade: bool) -> Result<()> @@ -601,7 +601,7 @@ impl<'conn> Catalog<'conn> { /// /// - Returns an error if `table_name` cannot be converted to a /// [`TableName`](crate::TableName). - /// - Returns [`Error::Client`] if the `SELECT COUNT(*)` query fails + /// - Returns [`Error::Server`] if the `SELECT COUNT(*)` query fails /// (e.g. table does not exist). pub fn get_row_count(&self, table_name: T) -> Result where @@ -661,7 +661,7 @@ impl<'conn> Catalog<'conn> { /// /// # Errors /// - /// Returns [`Error::Client`] if the + /// Returns [`Error::Server`] if the /// `SELECT datname FROM pg_catalog.pg_database` query fails or a /// streaming error occurs while draining the result. pub fn get_database_names(&self) -> Result> { diff --git a/hyperdb-api/src/connection.rs b/hyperdb-api/src/connection.rs index 784cc06..c0c112f 100644 --- a/hyperdb-api/src/connection.rs +++ b/hyperdb-api/src/connection.rs @@ -317,10 +317,9 @@ impl Connection { escape_sql_path(database_path) )) { if !is_already_exists_error(&e) { - return Err(Error::with_cause( - format!("Failed to create database '{database_path}': {e}"), - e, - )); + return Err(Error::internal(format!( + "Failed to create database '{database_path}': {e}" + ))); } } } @@ -391,7 +390,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the TCP or gRPC handshake fails, and + /// Returns [`Error::Connection`] if the TCP or gRPC handshake fails, and /// [`Error::Io`] if the endpoint cannot be reached. pub fn without_database(endpoint: &str) -> Result { crate::ConnectionBuilder::new(endpoint).build() @@ -462,7 +461,7 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Client`] wrapping a `hyperdb_api_core::client::Error` if the + /// - Returns [`Error::Server`] wrapping a `hyperdb_api_core::client::Error` if the /// SQL fails to parse, execute, or if the server reports an error /// while streaming. /// - Returns [`Error::Io`] on transport-level I/O failures. @@ -532,7 +531,7 @@ impl Connection { /// /// # Errors /// - /// Propagates any [`Error::Client`] from the TCP or gRPC transport when + /// Propagates any [`Error::Server`] from the TCP or gRPC transport when /// the query fails or the server cannot produce Arrow IPC output. pub fn execute_query_to_arrow(&self, select_query: &str) -> Result { self.transport.execute_query_to_arrow(select_query) @@ -567,7 +566,7 @@ impl Connection { /// /// Returns whatever [`execute_query_to_arrow`](Self::execute_query_to_arrow) /// would return for `SELECT * FROM ` — typically - /// [`Error::Client`] if the table does not exist or the query is rejected. + /// [`Error::Server`] if the table does not exist or the query is rejected. pub fn export_table_to_arrow(&self, table_name: &str) -> Result { self.execute_query_to_arrow(&format!("SELECT * FROM {table_name}")) } @@ -597,8 +596,8 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Client`] if the query itself fails. - /// - Returns [`Error::Other`] if the Arrow IPC payload returned by the + /// - Returns [`Error::Server`] if the query itself fails. + /// - Returns [`Error::Conversion`] if the Arrow IPC payload returned by the /// server is malformed and cannot be decoded into record batches. pub fn execute_query_to_batches( &self, @@ -630,7 +629,7 @@ impl Connection { /// /// - Returns the error from [`execute_query`](Self::execute_query) if /// the query itself fails. - /// - Returns [`Error::Other`] with message `"Query returned no rows"` if + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` if /// the query produced zero rows. pub fn fetch_one(&self, query: Q) -> Result where @@ -721,7 +720,7 @@ impl Connection { /// impl FromRow for User { /// fn from_row(row: &Row) -> Result { /// Ok(User { - /// id: row.get::(0).ok_or_else(|| hyperdb_api::Error::new("NULL id"))?, + /// id: row.get::(0).ok_or_else(|| hyperdb_api::Error::conversion("NULL id"))?, /// name: row.get::(1).unwrap_or_default(), /// }) /// } @@ -795,9 +794,9 @@ impl Connection { /// /// - Returns the error from [`execute_query`](Self::execute_query) if /// the query itself fails. - /// - Returns [`Error::Other`] with message `"Query returned no rows"` if + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` if /// the query produced zero rows. - /// - Returns [`Error::Other`] with message `"Scalar query returned NULL"` + /// - Returns [`Error::Conversion`] with message `"Scalar query returned NULL"` /// if the single cell is SQL `NULL`. pub fn fetch_scalar(&self, query: Q) -> Result where @@ -830,7 +829,7 @@ impl Connection { /// /// - Returns the error from [`execute_query`](Self::execute_query) if /// the query itself fails. - /// - Returns [`Error::Other`] with message `"Query returned no rows"` if + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` if /// the query produced zero rows. (An empty result is treated as an /// error here because we need at least one row to inspect; SQL `NULL` /// in the single cell yields `Ok(None)`.) @@ -956,9 +955,9 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Other`] if the connection is using gRPC transport + /// - Returns [`Error::FeatureNotSupported`] if the connection is using gRPC transport /// (prepared statements are TCP-only). - /// - Returns [`Error::Client`] if the server rejects the statement at + /// - Returns [`Error::Server`] if the server rejects the statement at /// `Parse`, `Bind`, or `Execute` time, including on type-mismatch /// between `params` and the inferred OIDs. /// - Returns [`Error::Io`] on transport-level I/O failures. @@ -977,7 +976,7 @@ impl Connection { let client = match &self.transport { Transport::Tcp(tcp) => &tcp.client, Transport::Grpc(_) => { - return Err(Error::new( + return Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )); } @@ -1011,8 +1010,8 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Other`] if the connection is using gRPC transport. - /// - Returns [`Error::Client`] if the server rejects the statement at + /// - Returns [`Error::FeatureNotSupported`] if the connection is using gRPC transport. + /// - Returns [`Error::Server`] if the server rejects the statement at /// `Parse`, `Bind`, or `Execute` time. /// - Returns [`Error::Io`] on transport-level I/O failures. pub fn command_params( @@ -1025,7 +1024,7 @@ impl Connection { let client = match &self.transport { Transport::Tcp(tcp) => &tcp.client, Transport::Grpc(_) => { - return Err(Error::new( + return Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )); } @@ -1064,8 +1063,8 @@ impl Connection { /// /// # Errors /// - /// Returns a wrapped [`Error::Other`] on the first statement that fails; - /// its `source` is the original [`Error::Client`] from + /// Returns a wrapped [`Error::Internal`] on the first statement that fails; + /// its `source` is the original [`Error::Server`] from /// [`execute_command`](Self::execute_command). The error message /// includes the failing statement's ordinal and an 80-character preview /// of its SQL. @@ -1075,15 +1074,13 @@ impl Connection { if !stmt.trim().is_empty() { total += self.execute_command(stmt).map_err(|e| { let preview: String = stmt.chars().take(80).collect(); - Error::with_cause( - format!( - "execute_batch failed at statement {} of {}: {}", - i + 1, - statements.len(), - preview, - ), + Error::internal(format!( + "execute_batch failed at statement {} of {}: {}: {}", + i + 1, + statements.len(), + preview, e, - ) + )) })?; } } @@ -1111,7 +1108,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects the + /// Returns [`Error::Server`] if the server rejects the /// `CREATE DATABASE IF NOT EXISTS` statement (e.g. the path is not /// writable on the server). pub fn create_database(&self, path: &str) -> Result<()> { @@ -1136,7 +1133,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects the + /// Returns [`Error::Server`] if the server rejects the /// `DROP DATABASE IF EXISTS` statement (e.g. the database is still /// attached or permissions deny deletion). pub fn drop_database(&self, path: &str) -> Result<()> { @@ -1230,7 +1227,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects the + /// Returns [`Error::Server`] if the server rejects the /// `DETACH ALL DATABASES` statement (e.g. a database is still in use by /// another session). pub fn detach_all_databases(&self) -> Result<()> { @@ -1244,7 +1241,7 @@ impl Connection { /// /// - Returns an error if `schema_name` cannot be converted into a /// [`SchemaName`](crate::SchemaName) (invalid identifier). - /// - Returns [`Error::Client`] if the server rejects the + /// - Returns [`Error::Server`] if the server rejects the /// `CREATE SCHEMA` statement (e.g. the schema already exists). pub fn create_schema(&self, schema_name: T) -> Result<()> where @@ -1278,7 +1275,7 @@ impl Connection { /// /// - Returns an error if `schema` cannot be converted into a /// [`SchemaName`](crate::SchemaName). - /// - Returns [`Error::Client`] if the catalog lookup query fails. + /// - Returns [`Error::Server`] if the catalog lookup query fails. pub fn has_schema(&self, schema: T) -> Result where T: TryInto, @@ -1312,7 +1309,7 @@ impl Connection { /// /// - Returns an error if `table_name` cannot be converted into a /// [`TableName`](crate::TableName). - /// - Returns [`Error::Client`] if the catalog lookup query fails. + /// - Returns [`Error::Server`] if the catalog lookup query fails. pub fn has_table(&self, table_name: T) -> Result where T: TryInto, @@ -1367,7 +1364,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects the + /// Returns [`Error::Server`] if the server rejects the /// `COPY DATABASE` statement — e.g. the source is not attached, the /// destination path is not writable, or it already exists. pub fn copy_database(&self, source: &str, destination: &str) -> Result<()> { @@ -1397,7 +1394,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if `EXPLAIN ` fails to parse or + /// Returns [`Error::Server`] if `EXPLAIN ` fails to parse or /// plan, or if the streamed result cannot be consumed. pub fn explain(&self, query: &str) -> Result { let explain_sql = format!("EXPLAIN {query}"); @@ -1418,7 +1415,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if `EXPLAIN ANALYZE ` fails — this + /// Returns [`Error::Server`] if `EXPLAIN ANALYZE ` fails — this /// includes any runtime error raised by actually executing `query`. pub fn explain_analyze(&self, query: &str) -> Result { let explain_sql = format!("EXPLAIN ANALYZE {query}"); @@ -1494,9 +1491,9 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Other`] if the connection is using gRPC transport + /// - Returns [`Error::FeatureNotSupported`] if the connection is using gRPC transport /// (prepared statements are TCP-only). - /// - Returns [`Error::Client`] if the server rejects the `Parse` + /// - Returns [`Error::Server`] if the server rejects the `Parse` /// message, e.g. SQL syntax error or unknown OID. /// - Returns [`Error::Io`] on transport-level I/O failures. pub fn prepare_typed( @@ -1507,7 +1504,7 @@ impl Connection { let client = match &self.transport { Transport::Tcp(tcp) => &tcp.client, Transport::Grpc(_) => { - return Err(Error::new( + return Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )); } @@ -1546,7 +1543,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] or [`Error::Io`] if the `SELECT 1` + /// Returns [`Error::Server`] or [`Error::Io`] if the `SELECT 1` /// round-trip fails — i.e. the connection is no longer usable. pub fn ping(&self) -> Result<()> { self.execute_command("SELECT 1")?; @@ -1627,14 +1624,14 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC connections — cancellation is not + /// - Returns [`Error::FeatureNotSupported`] on gRPC connections — cancellation is not /// yet implemented for gRPC transport. - /// - Returns [`Error::Client`] or [`Error::Io`] if the separate + /// - Returns [`Error::Connection`] or [`Error::Io`] if the separate /// cancel-request connection to the server fails. pub fn cancel(&self) -> Result<()> { match &self.transport { Transport::Tcp(tcp) => tcp.client.cancel().map_err(Error::from), - Transport::Grpc(_) => Err(Error::new( + Transport::Grpc(_) => Err(Error::feature_not_supported( "Query cancellation is not yet supported for gRPC connections.", )), } @@ -1644,10 +1641,10 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Other`] wrapping the underlying close failure + /// - Returns [`Error::Internal`] wrapping the underlying close failure /// (its `source` is the transport error) if the client cannot be /// shut down cleanly. - /// - Returns [`Error::Other`] wrapping the detach failure if the + /// - Returns [`Error::Internal`] wrapping the detach failure if the /// attached database could not be detached but close itself /// succeeded. pub fn close(self) -> Result<()> { @@ -1671,15 +1668,14 @@ impl Connection { }; if let Err(e) = close_result { - return Err(Error::with_cause("Failed to close connection", e)); + return Err(Error::internal(format!("Failed to close connection: {e}"))); } if let Some(e) = detach_err { // Detach failed but close succeeded; surface the detach error. - return Err(Error::with_cause( - "Failed to detach database during close", - e, - )); + return Err(Error::internal(format!( + "Failed to detach database during close: {e}" + ))); } Ok(()) @@ -1719,7 +1715,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects the `UNLOAD DATABASE` + /// Returns [`Error::Server`] if the server rejects the `UNLOAD DATABASE` /// command (e.g. the database is still in use by another session). pub fn unload_database(&self) -> Result<()> { self.execute_command("UNLOAD DATABASE")?; @@ -1763,7 +1759,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `UNLOAD RELEASE`, most + /// Returns [`Error::Server`] if the server rejects `UNLOAD RELEASE`, most /// commonly because multiple databases are attached to the same session /// (Hyper only supports `UNLOAD RELEASE` with exactly one attached DB). pub fn unload_release(&self) -> Result<()> { @@ -1902,7 +1898,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `BEGIN TRANSACTION` + /// Returns [`Error::Server`] if the server rejects `BEGIN TRANSACTION` /// (e.g. a transaction is already open on this session). pub fn begin_transaction(&self) -> Result<()> { self.execute_command("BEGIN TRANSACTION")?; @@ -1913,7 +1909,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `COMMIT` — most + /// Returns [`Error::Server`] if the server rejects `COMMIT` — most /// commonly because no transaction is currently open. pub fn commit(&self) -> Result<()> { self.execute_command("COMMIT")?; @@ -1924,7 +1920,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects `ROLLBACK` — most + /// Returns [`Error::Server`] if the server rejects `ROLLBACK` — most /// commonly because no transaction is currently open. pub fn rollback(&self) -> Result<()> { self.execute_command("ROLLBACK")?; @@ -1954,7 +1950,7 @@ impl Connection { /// /// # Errors /// - /// Returns [`Error::Client`] if the server rejects the `BEGIN` + /// Returns [`Error::Server`] if the server rejects the `BEGIN` /// statement issued internally by /// [`Transaction::new`](crate::Transaction). pub fn transaction(&mut self) -> Result> { diff --git a/hyperdb-api/src/connection_builder.rs b/hyperdb-api/src/connection_builder.rs index 6a1a8cb..48bee6c 100644 --- a/hyperdb-api/src/connection_builder.rs +++ b/hyperdb-api/src/connection_builder.rs @@ -234,7 +234,7 @@ impl ConnectionBuilder { let mut config: Config = self .endpoint .parse() - .map_err(|e| Error::new(format!("invalid endpoint: {e}")))?; + .map_err(|e| Error::config(format!("invalid endpoint: {e}")))?; if let Some(user) = &self.user { config = config.with_user(user); @@ -279,11 +279,11 @@ impl ConnectionBuilder { let socket_path = if self.endpoint.starts_with("tab.domain://") { // Format: tab.domain:///domain/ let endpoint = ConnectionEndpoint::parse(&self.endpoint) - .map_err(|e| Error::new(format!("invalid Unix socket endpoint: {e}")))?; + .map_err(|e| Error::config(format!("invalid Unix socket endpoint: {e}")))?; match endpoint { ConnectionEndpoint::DomainSocket { directory, name } => directory.join(&name), ConnectionEndpoint::Tcp { .. } => { - return Err(Error::new("expected Unix domain socket endpoint")) + return Err(Error::config("expected Unix domain socket endpoint")) } } } else { @@ -329,12 +329,12 @@ impl ConnectionBuilder { let pipe_path = if self.endpoint.starts_with("tab.pipe://") { // Format: tab.pipe:///pipe/ let endpoint = ConnectionEndpoint::parse(&self.endpoint) - .map_err(|e| Error::new(format!("invalid named pipe endpoint: {e}")))?; + .map_err(|e| Error::config(format!("invalid named pipe endpoint: {e}")))?; match endpoint { ConnectionEndpoint::NamedPipe { host, name } => { format!(r"\\{host}\pipe\{name}") } - _ => return Err(Error::new("expected named pipe endpoint")), + _ => return Err(Error::config("expected named pipe endpoint")), } } else { // Treat as direct pipe path (e.g., \\.\pipe\hyper-12345) @@ -374,7 +374,7 @@ impl ConnectionBuilder { fn build_grpc(self) -> Result { // Validate create_mode - gRPC is read-only if self.create_mode != CreateMode::DoNotCreate { - return Err(Error::new( + return Err(Error::feature_not_supported( "gRPC transport is read-only. Use CreateMode::DoNotCreate for gRPC connections.", )); } diff --git a/hyperdb-api/src/copy.rs b/hyperdb-api/src/copy.rs index d02ea1f..20582e5 100644 --- a/hyperdb-api/src/copy.rs +++ b/hyperdb-api/src/copy.rs @@ -189,13 +189,13 @@ impl CopyOptions { fn validate(&self) -> Result<()> { if self.format == CopyFormat::Text { if self.quote.is_some() { - return Err(Error::new( + return Err(Error::config( "QUOTE option is only supported with CSV format. \ Use CopyOptions::csv() instead of CopyOptions::text().", )); } if self.escape.is_some() { - return Err(Error::new( + return Err(Error::config( "ESCAPE option is only supported with CSV format. \ Use CopyOptions::csv() instead of CopyOptions::text().", )); @@ -268,9 +268,9 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Other`] if the connection is using gRPC transport + /// - Returns [`Error::FeatureNotSupported`] if the connection is using gRPC transport /// (COPY is TCP-only). - /// - Returns [`Error::Client`] if the server rejects the + /// - Returns [`Error::Server`] if the server rejects the /// `COPY () TO STDOUT` statement. /// - Returns [`Error::Io`] if writing to `writer` fails. pub fn export_csv(&self, select_query: &str, writer: &mut dyn std::io::Write) -> Result { @@ -311,10 +311,10 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Other`] if `options` fail validation (e.g. an - /// illegal delimiter/quote combination) or the connection is on - /// gRPC. - /// - Returns [`Error::Client`] if the server rejects the + /// - Returns [`Error::Config`] if `options` fail validation (e.g. an + /// illegal delimiter/quote combination), or + /// [`Error::FeatureNotSupported`] if the connection is on gRPC. + /// - Returns [`Error::Server`] if the server rejects the /// `COPY TO STDOUT` statement. /// - Returns [`Error::Io`] if writing to `writer` fails. pub fn export_text( @@ -330,7 +330,7 @@ impl Connection { options.to_copy_out_options() ); let client = self.tcp_client().ok_or_else(|| { - Error::new( + Error::feature_not_supported( "CSV export requires a TCP connection. gRPC does not support COPY operations.", ) })?; @@ -358,7 +358,7 @@ impl Connection { /// # Errors /// /// - Returns whatever [`export_csv`](Self::export_csv) returns. - /// - Returns [`Error::Other`] with message + /// - Returns [`Error::Conversion`] with message /// `"CSV output is not valid UTF-8"` if the server emitted bytes that /// are not valid UTF-8 (a Hyper server only emits UTF-8, so this /// indicates a non-UTF-8 `CLIENT_ENCODING` setting). @@ -366,7 +366,7 @@ impl Connection { let mut buf = Vec::new(); self.export_csv(select_query, &mut buf)?; String::from_utf8(buf) - .map_err(|e| Error::new(format!("CSV output is not valid UTF-8: {e}"))) + .map_err(|e| Error::conversion(format!("CSV output is not valid UTF-8: {e}"))) } /// Imports CSV data from a reader into a table. @@ -452,9 +452,9 @@ impl Connection { /// /// # Errors /// - /// - Returns [`Error::Other`] if `options` fail validation or the - /// connection is on gRPC. - /// - Returns [`Error::Client`] if the server rejects the + /// - Returns [`Error::Config`] if `options` fail validation, or + /// [`Error::FeatureNotSupported`] if the connection is on gRPC. + /// - Returns [`Error::Server`] if the server rejects the /// `COPY FROM STDIN` statement or a row during import. /// - Returns [`Error::Io`] if reading from `reader` fails. pub fn import_text( @@ -472,7 +472,7 @@ impl Connection { ); let client = self.tcp_client().ok_or_else(|| { - Error::new( + Error::feature_not_supported( "CSV import requires a TCP connection. gRPC does not support COPY operations.", ) })?; @@ -485,7 +485,7 @@ impl Connection { loop { let n = reader .read(&mut buf) - .map_err(|e| Error::with_cause("Failed to read import data", e))?; + .map_err(|e| Error::connection_with_io("Failed to read import data", e))?; if n == 0 { break; } diff --git a/hyperdb-api/src/error.rs b/hyperdb-api/src/error.rs index 5413193..ed8ae48 100644 --- a/hyperdb-api/src/error.rs +++ b/hyperdb-api/src/error.rs @@ -2,126 +2,608 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT //! Error types for the pure Rust Hyper API. +//! +//! Callers match directly on [`Error`] variants. There is no `kind()` +//! indirection, no `Other` catch-all, and no `Box` +//! cause channel — see the [Microsoft Pragmatic Rust Guidelines][1] +//! M-ERRORS-CANONICAL-STRUCTS and M-ERRORS-AVOID-WRAPPING-AND-AS-DYN. +//! +//! Internal errors from [`hyperdb_api_core::client::Error`] are mapped +//! into this flat enum at the crate boundary via the `From` impl below. +//! +//! [1]: https://microsoft.github.io/rust-guidelines/ -use hyperdb_api_core::client::ErrorKind; -use std::error::Error as StdError; use thiserror::Error as ThisError; /// The error type for Hyper API operations. /// -/// This enum is `#[non_exhaustive]`: new variants and new fields on existing -/// struct variants may be added in minor releases. Match arms must include a -/// wildcard `_ =>` pattern. +/// This enum is `#[non_exhaustive]`: new variants may be added in minor +/// releases, so match arms must include a wildcard `_ =>` pattern. +/// +/// Struct variants (`Connection`, `Server`, `Column`, +/// `ColumnIndexOutOfBounds`, `Internal`) cannot use Rust's +/// `#[non_exhaustive]` (E0639), so forward-compatibility for new fields +/// relies on construction via the provided constructors: +/// +/// - [`Self::internal`] for [`Self::Internal`] +/// - [`Self::connection`] / [`Self::connection_with_io`] for [`Self::Connection`] +/// - [`Self::server`] for [`Self::Server`] +/// - [`Self::column`] for [`Self::Column`] +/// - [`Self::column_index_out_of_bounds`] for [`Self::ColumnIndexOutOfBounds`] +/// +/// Downstream code that uses struct-expression syntax for these +/// variants will fail to compile if a new field is added in a minor +/// release; using the constructors keeps callers source-compatible. #[derive(Debug, ThisError)] #[non_exhaustive] pub enum Error { - /// Error from the underlying Hyper client. - #[error("{0}")] - Client(#[from] hyperdb_api_core::client::Error), + // ---- Connection / transport ---------------------------------------- + /// Connection-level failure (network, handshake, lifecycle, socket + /// I/O). Carries the underlying [`std::io::Error`] when one is + /// available; the type is erased at the wire-protocol boundary in + /// `hyperdb-api-core`, so `source` is `None` for errors that + /// originated there. + /// + /// Construct via [`Self::connection`] or [`Self::connection_with_io`]. + #[error("connection error: {message}")] + Connection { + /// Human-readable description. + message: String, + /// Underlying I/O error, if available. + #[source] + source: Option, + }, - /// I/O error. + /// Authentication failed. + #[error("authentication failed: {0}")] + Authentication(String), + + /// TLS handshake or configuration failure. + #[error("TLS error: {0}")] + Tls(String), + + // ---- Server-side --------------------------------------------------- + /// Server-side error (a SQL query or DDL command failed at the + /// server). `sqlstate` is the 5-character `PostgreSQL` SQLSTATE + /// code when the server reported one. `detail` and `hint` mirror + /// the structured fields the server may include in its error + /// response and are appended to the `Display` output when present. + #[error( + "server error{}: {message}{}{}", + sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(), + detail.as_ref().map(|d| format!("\nDETAIL: {d}")).unwrap_or_default(), + hint.as_ref().map(|h| format!("\nHINT: {h}")).unwrap_or_default(), + )] + Server { + /// The 5-character `PostgreSQL` SQLSTATE code, if reported. + sqlstate: Option, + /// The primary error message from the server. + message: String, + /// Additional detail line from the server's error response. + detail: Option, + /// Resolution hint from the server's error response. + hint: Option, + }, + + /// Wire-protocol or framing error. + #[error("protocol error: {0}")] + Protocol(String), + + // ---- I/O ----------------------------------------------------------- + /// Direct I/O error (file system, non-network sockets) at the SDK + /// boundary. Network I/O during connection lifecycle is reported as + /// [`Self::Connection`] instead. #[error("I/O error: {0}")] Io(#[from] std::io::Error), - /// Invalid name error (empty or too long). - #[error("Invalid name: {0}")] + // ---- Lifecycle ----------------------------------------------------- + /// Operation attempted on a closed connection. + #[error("connection closed: {0}")] + Closed(String), + + /// Operation timed out. + #[error("operation timed out: {0}")] + Timeout(String), + + /// Operation was cancelled. + #[error("operation cancelled: {0}")] + Cancelled(String), + + // ---- Type / value -------------------------------------------------- + /// Type or value conversion failed (out-of-range numeric, malformed + /// binary value, scalar query returned no rows, etc.). For + /// column-specific decoding errors, prefer [`Self::Column`]. + #[error("conversion error: {0}")] + Conversion(String), + + /// Configuration error (invalid endpoint, missing env var, bad + /// option combination). + #[error("configuration error: {0}")] + Config(String), + + /// Feature is not supported on this connection or transport. + #[error("feature not supported: {0}")] + FeatureNotSupported(String), + + // ---- Catalog / validation ------------------------------------------ + /// Database identifier is invalid (empty, exceeds the `PostgreSQL` + /// 63-byte limit, or violates other naming rules). + #[error("invalid name: {0}")] InvalidName(String), - /// Invalid table definition. - #[error("Invalid table definition: {0}")] + /// Table definition is invalid (zero columns, conflicting + /// attributes). + #[error("invalid table definition: {0}")] InvalidTableDefinition(String), - /// Database object not found (table, schema, etc.). - #[error("Not found: {0}")] + /// Database object (schema, table, etc.) was not found. + #[error("not found: {0}")] NotFound(String), /// Database object already exists. - #[error("Already exists: {0}")] + #[error("already exists: {0}")] AlreadyExists(String), - /// Generic error with a custom message. - #[error("{message}")] - #[non_exhaustive] - Other { - /// The error message. - message: String, - /// The underlying cause of the error, if any. + // ---- Column / row mapping ------------------------------------------ + /// Structured error for named-column access in row decoding. Used + /// by `FromRow` impls and `Row::try_get` / `Row::get_by_name` to + /// signal which column failed and why. + #[error("column {name}: {kind}")] + Column { + /// The column name. + name: String, + /// The structured cause of the column-access failure. #[source] - source: Option>, + kind: ColumnErrorKind, + }, + + /// Column index was out of bounds for the row. Used for positional + /// access; named access uses [`Self::Column`] with + /// [`ColumnErrorKind::Missing`]. + #[error("column index {idx} out of bounds (row has {column_count} columns)")] + ColumnIndexOutOfBounds { + /// The requested 0-based column index. + idx: usize, + /// The actual column count of the row. + column_count: usize, + }, + + // ---- Internal ------------------------------------------------------ + /// Internal invariant violation. Used as a default for state + /// assertions that should be unreachable in correct callers; + /// callers generally cannot recover beyond logging and bailing. + /// + /// Construction of this variant should be rare — every site is a + /// candidate for either a more specific variant or removal once + /// the assertion is proven unreachable. + /// + /// Construct via [`Self::internal`]. + #[error("internal error: {message}")] + Internal { + /// Human-readable description of what invariant was violated. + message: String, + }, +} + +/// The structured cause of an [`Error::Column`]. +#[derive(Debug, ThisError)] +#[non_exhaustive] +pub enum ColumnErrorKind { + /// Column name was not found in the result schema. + #[error("column not found")] + Missing, + + /// Column was SQL `NULL` but the target type was not `Option`. + #[error("unexpected NULL")] + Null, + + /// Column value could not be decoded as the target type. + #[error("type mismatch: expected {expected}, got {actual}")] + TypeMismatch { + /// Rust type name the caller asked for. + expected: String, + /// Hyper SQL type name (or descriptive label) of the column. + actual: String, }, } impl Error { - /// Creates a new error with the given message. - /// - /// This is a convenience constructor for creating generic errors. - pub fn new(message: impl Into) -> Self { - Error::Other { + /// Constructs an [`Self::Internal`] error. Prefer this over + /// struct-expression syntax to remain source-compatible if new + /// fields are added in a minor release. + pub fn internal(message: impl Into) -> Self { + Error::Internal { + message: message.into(), + } + } + + /// Constructs an [`Self::Connection`] error with no underlying I/O + /// source. Prefer this over struct-expression syntax to remain + /// source-compatible if new fields are added in a minor release. + pub fn connection(message: impl Into) -> Self { + Error::Connection { message: message.into(), source: None, } } - /// Creates a new error with a cause. - /// - /// This is a convenience constructor for creating generic errors with a source. - pub fn with_cause(message: impl Into, cause: E) -> Self - where - E: Into>, - { - Error::Other { + /// Constructs an [`Self::Connection`] error wrapping an underlying + /// [`std::io::Error`]. Prefer this over struct-expression syntax + /// to remain source-compatible if new fields are added in a minor + /// release. + pub fn connection_with_io(message: impl Into, source: std::io::Error) -> Self { + Error::Connection { message: message.into(), - source: Some(cause.into()), + source: Some(source), } } - /// Returns the error kind, if this is a client error. - /// - /// This is available when the error originates from `hyperdb_api_core::client::Error`. - /// Use this for matching on error categories (e.g., `ErrorKind::Connection`). - #[must_use] - pub fn kind(&self) -> Option { - match self { - Error::Client(err) => Some(err.kind()), - _ => None, + /// Constructs an [`Self::Server`] error. Prefer this over + /// struct-expression syntax to remain source-compatible if new + /// fields are added in a minor release. + pub fn server( + sqlstate: Option, + message: impl Into, + detail: Option, + hint: Option, + ) -> Self { + Error::Server { + sqlstate, + message: message.into(), + detail, + hint, } } - /// Returns the error message. + /// Constructs an [`Self::Column`] error. Prefer this over + /// struct-expression syntax to remain source-compatible if new + /// fields are added in a minor release. + pub fn column(name: impl Into, kind: ColumnErrorKind) -> Self { + Error::Column { + name: name.into(), + kind, + } + } + + /// Constructs an [`Self::ColumnIndexOutOfBounds`] error. Prefer + /// this over struct-expression syntax to remain source-compatible + /// if new fields are added in a minor release. + pub fn column_index_out_of_bounds(idx: usize, column_count: usize) -> Self { + Error::ColumnIndexOutOfBounds { idx, column_count } + } + + // ---- Tuple-variant constructors ------------------------------------ + // + // These accept `impl Into` so callers can pass either `&str`, + // `String`, or `format!(...)` without the `.to_string()` / `.into()` + // ceremony every direct construction would otherwise require. + + /// Constructs an [`Self::Authentication`] error. + pub fn authentication(message: impl Into) -> Self { + Error::Authentication(message.into()) + } + + /// Constructs an [`Self::Tls`] error. + pub fn tls(message: impl Into) -> Self { + Error::Tls(message.into()) + } + + /// Constructs an [`Self::Protocol`] error. + pub fn protocol(message: impl Into) -> Self { + Error::Protocol(message.into()) + } + + /// Constructs an [`Self::Closed`] error. + pub fn closed(message: impl Into) -> Self { + Error::Closed(message.into()) + } + + /// Constructs an [`Self::Timeout`] error. + pub fn timeout(message: impl Into) -> Self { + Error::Timeout(message.into()) + } + + /// Constructs an [`Self::Cancelled`] error. + pub fn cancelled(message: impl Into) -> Self { + Error::Cancelled(message.into()) + } + + /// Constructs an [`Self::Conversion`] error. + pub fn conversion(message: impl Into) -> Self { + Error::Conversion(message.into()) + } + + /// Constructs an [`Self::Config`] error. + pub fn config(message: impl Into) -> Self { + Error::Config(message.into()) + } + + /// Constructs an [`Self::FeatureNotSupported`] error. + pub fn feature_not_supported(message: impl Into) -> Self { + Error::FeatureNotSupported(message.into()) + } + + /// Constructs an [`Self::InvalidName`] error. + pub fn invalid_name(message: impl Into) -> Self { + Error::InvalidName(message.into()) + } + + /// Constructs an [`Self::InvalidTableDefinition`] error. + pub fn invalid_table_definition(message: impl Into) -> Self { + Error::InvalidTableDefinition(message.into()) + } + + /// Constructs an [`Self::NotFound`] error. + pub fn not_found(message: impl Into) -> Self { + Error::NotFound(message.into()) + } + + /// Constructs an [`Self::AlreadyExists`] error. + pub fn already_exists(message: impl Into) -> Self { + Error::AlreadyExists(message.into()) + } + + /// Returns the error message in human-readable form. Equivalent to + /// `self.to_string()`. #[must_use] pub fn message(&self) -> String { self.to_string() } - /// Extracts the `PostgreSQL` SQLSTATE code from the error, if available. - /// - /// This is only available for database query errors from the Hyper client. - /// - /// # Example + /// Returns the `PostgreSQL` SQLSTATE code if this is a + /// [`Self::Server`] error that carries one, otherwise `None`. /// - /// ``` - /// use hyperdb_api::Error; + /// SQLSTATE codes are 5-character strings — see the [`PostgreSQL` + /// errcodes appendix][1]. /// - /// // Assuming we have a client error with SQLSTATE - /// // let err: Error = ...; - /// // if let Some("42P04") = err.sqlstate() { - /// // println!("Database already exists"); - /// // } - /// ``` + /// [1]: https://www.postgresql.org/docs/current/errcodes-appendix.html #[must_use] pub fn sqlstate(&self) -> Option<&str> { match self { - Error::Client(err) => err.sqlstate(), + Error::Server { sqlstate, .. } => sqlstate.as_deref(), _ => None, } } } +// Internal mapping: `client::Error` → public `Error`. The mapping is +// exhaustive over `client::ErrorKind` (verified to NOT be +// `#[non_exhaustive]`); adding a kind in `hyperdb-api-core` will break +// this build until the mapping is updated, which is intended. +// +// `chain = err.to_string()` walks the inner error's full Display chain +// (message + cause + detail). We use it for tuple variants whose +// `Display` is just `": {0}"`, where embedding the chain into +// the single string field gives the caller the full picture. +// +// For the `Server` variant we use the *un-chained* `message` and pass +// `detail`/`hint` separately; the `Server` `Display` impl re-appends +// "DETAIL: ..." and "HINT: ..." lines from those fields, so using +// `chain` would duplicate the detail text. +// +// SQLSTATE: `client::Error::sqlstate()` may return `Some` for non-Query +// kinds (e.g. SQLSTATE 57014 query_canceled comes back as Cancelled). +// The flat enum only carries `sqlstate` on `Server`, so SQLSTATE codes +// from non-Query kinds are folded into the message via `chain` rather +// than surfaced via `Error::sqlstate()`. Documented in MIGRATING-0.3. +impl From for Error { + fn from(err: hyperdb_api_core::client::Error) -> Self { + use hyperdb_api_core::client::ErrorKind as CoreKind; + + let chain = err.to_string(); + let kind = err.kind(); + let sqlstate = err.sqlstate().map(str::to_string); + let detail = err.detail().map(str::to_string); + let hint = err.hint().map(str::to_string); + let message = err.message().to_string(); + + match kind { + CoreKind::Connection => Error::Connection { + message: chain, + source: None, + }, + CoreKind::Authentication => Error::Authentication(chain), + // Use unchained `message` here: detail/hint are passed as + // separate fields and the `Server` Display impl re-renders + // them. Using `chain` would duplicate detail text. + CoreKind::Query => Error::Server { + sqlstate, + message, + detail, + hint, + }, + CoreKind::Protocol => Error::Protocol(chain), + // Wire-level I/O failures are reported as Connection errors + // (the underlying io::Error is type-erased in core, so we + // cannot recover it as a typed `source` here). + CoreKind::Io => Error::Connection { + message: chain, + source: None, + }, + CoreKind::Config => Error::Config(chain), + CoreKind::Timeout => Error::Timeout(chain), + CoreKind::Cancelled => Error::Cancelled(chain), + CoreKind::Closed => Error::Closed(chain), + CoreKind::Conversion => Error::Conversion(chain), + CoreKind::FeatureNotSupported => Error::FeatureNotSupported(chain), + CoreKind::Other => Error::Internal { message: chain }, + } + } +} + +// `Infallible` is the error type for identity `TryFrom`/`TryInto` +// conversions. Generic APIs that take `T: TryInto` and bound +// `Error: From` (e.g. `TableDefinition::from_table_name`) +// require this impl to compile when callers pass a value that is +// already the target type. The body is unreachable because +// `Infallible` has no values. impl From for Error { fn from(_: std::convert::Infallible) -> Self { - unreachable!() + unreachable!("Infallible has no values") } } /// Result type for Hyper API operations. pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + use hyperdb_api_core::client::{Error as CoreError, ErrorKind as CoreKind}; + + #[test] + fn server_display_includes_sqlstate_detail_and_hint() { + let err = Error::server( + Some("23505".to_string()), + "duplicate key value violates unique constraint", + Some("Key (id)=(42) already exists.".to_string()), + Some("Choose a different key.".to_string()), + ); + let s = err.to_string(); + assert!(s.contains("server error (23505)"), "got: {s}"); + assert!( + s.contains("duplicate key value violates unique constraint"), + "got: {s}" + ); + assert!( + s.contains("\nDETAIL: Key (id)=(42) already exists."), + "got: {s}" + ); + assert!(s.contains("\nHINT: Choose a different key."), "got: {s}"); + } + + #[test] + fn server_display_omits_missing_optional_fields() { + let err = Error::server(None, "syntax error at end of input", None, None); + let s = err.to_string(); + assert_eq!(s, "server error: syntax error at end of input"); + } + + #[test] + fn from_client_error_query_does_not_duplicate_detail() { + // Build a client::Error with detail; client::Error::Display + // appends ": {detail}" inline. The flat-Error mapping must + // not also add "\nDETAIL: {detail}" — that would duplicate the + // text. We verify by counting occurrences. + let core = CoreError::new_with_details( + CoreKind::Query, + "duplicate key value", + Some("Key (id)=(42) already exists.".to_string()), + Some("Choose a different key.".to_string()), + Some("23505".to_string()), + ); + let public: Error = core.into(); + let s = public.to_string(); + // The detail text should appear exactly once in the rendered + // string. (Once on the DETAIL line; not also inline in message.) + let count = s.matches("Key (id)=(42) already exists.").count(); + assert_eq!(count, 1, "detail must appear exactly once; got: {s}"); + let hint_count = s.matches("Choose a different key.").count(); + assert_eq!(hint_count, 1, "hint must appear exactly once; got: {s}"); + // Verify SQLSTATE is preserved. + assert_eq!(public.sqlstate(), Some("23505")); + } + + #[test] + fn from_client_error_exhaustive_over_kinds() { + // Smoke test: every ErrorKind maps cleanly with no panic. + // (Compilation already enforces exhaustiveness.) + for kind in [ + CoreKind::Connection, + CoreKind::Authentication, + CoreKind::Query, + CoreKind::Protocol, + CoreKind::Io, + CoreKind::Config, + CoreKind::Timeout, + CoreKind::Cancelled, + CoreKind::Closed, + CoreKind::Conversion, + CoreKind::FeatureNotSupported, + CoreKind::Other, + ] { + let core = CoreError::new(kind, "test message"); + let public: Error = core.into(); + // Each variant's Display must include the message text. + assert!( + public.to_string().contains("test message"), + "{kind:?} mapping lost the message: {public}", + ); + } + } + + #[test] + fn sqlstate_returns_some_only_for_server() { + let server = Error::server(Some("42P04".to_string()), "db exists", None, None); + assert_eq!(server.sqlstate(), Some("42P04")); + + // Non-Server variants must return None even if the SQLSTATE + // would have been present in the underlying client::Error. + // Documented behavior: only Server-variant SQLSTATEs surface + // through Error::sqlstate() in the flat enum. + assert_eq!(Error::Conversion("...".into()).sqlstate(), None); + assert_eq!( + Error::Internal { + message: "...".into() + } + .sqlstate(), + None + ); + assert_eq!(Error::Cancelled("...".into()).sqlstate(), None); + } + + #[test] + fn column_display_formats_name_and_kind() { + let err = Error::column("user_id", ColumnErrorKind::Missing); + assert_eq!(err.to_string(), "column user_id: column not found"); + + let err = Error::column("score", ColumnErrorKind::Null); + assert_eq!(err.to_string(), "column score: unexpected NULL"); + + let err = Error::column( + "count", + ColumnErrorKind::TypeMismatch { + expected: "i32".into(), + actual: "TEXT".into(), + }, + ); + assert_eq!( + err.to_string(), + "column count: type mismatch: expected i32, got TEXT" + ); + } + + #[test] + fn column_index_out_of_bounds_display() { + let err = Error::column_index_out_of_bounds(5, 3); + assert_eq!( + err.to_string(), + "column index 5 out of bounds (row has 3 columns)" + ); + } + + #[test] + fn connection_display_with_typed_io_source() { + let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"); + let err = Error::connection_with_io("connecting to hyperd", io_err); + let s = err.to_string(); + // Top-level message is the prefixed form. + assert!( + s.contains("connection error: connecting to hyperd"), + "got: {s}" + ); + // The typed source is recoverable via std::error::Error::source(). + use std::error::Error as StdError; + let src = err.source().expect("connection_with_io must expose source"); + let io_src: &std::io::Error = src + .downcast_ref::() + .expect("source must downcast to io::Error"); + assert_eq!(io_src.kind(), std::io::ErrorKind::ConnectionRefused); + } + + #[test] + fn internal_constructor_round_trip() { + let err = Error::internal("invariant violated"); + assert_eq!(err.to_string(), "internal error: invariant violated"); + } +} diff --git a/hyperdb-api/src/grpc_connection.rs b/hyperdb-api/src/grpc_connection.rs index 593cc51..78e75f1 100644 --- a/hyperdb-api/src/grpc_connection.rs +++ b/hyperdb-api/src/grpc_connection.rs @@ -132,7 +132,7 @@ impl GrpcConnection { /// /// # Errors /// - /// Returns [`crate::Error::Client`] wrapping a + /// Returns [`crate::Error::Connection`] wrapping a /// `hyperdb_api_core::client::Error` if the HTTP/2 channel cannot be established /// (endpoint unreachable, TLS handshake failure, auth rejection). pub fn connect(endpoint: &str, database_path: &str) -> Result { @@ -165,7 +165,7 @@ impl GrpcConnection { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the HTTP/2 channel cannot be + /// Returns [`crate::Error::Connection`] if the HTTP/2 channel cannot be /// established with the supplied configuration. pub fn connect_with_config(config: GrpcConfig) -> Result { let database = config.database_path().map(std::string::ToString::to_string); @@ -194,7 +194,7 @@ impl GrpcConnection { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the gRPC server rejects the query + /// Returns [`crate::Error::Server`] if the gRPC server rejects the query /// or if the HTTP/2 channel fails mid-stream. pub fn execute_query_to_arrow(&mut self, sql: &str) -> Result { Ok(self.client.execute_query_to_arrow(sql)?) @@ -220,7 +220,7 @@ impl GrpcConnection { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the gRPC server rejects the query + /// Returns [`crate::Error::Server`] if the gRPC server rejects the query /// or the HTTP/2 channel fails. pub fn execute_query(&mut self, sql: &str) -> Result { Ok(self.client.execute_query(sql)?) @@ -276,7 +276,7 @@ impl GrpcConnection { /// /// # Errors /// - /// Returns [`crate::Error::Client`] on transport-level failures (channel + /// Returns [`crate::Error::Connection`] on transport-level failures (channel /// closed, network error, auth expired). An unknown or already-finished /// `query_id` is not an error — the server returns `Ok`. pub fn cancel_query(&mut self, query_id: &str) -> Result<()> { @@ -300,7 +300,7 @@ impl GrpcConnection { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the underlying HTTP/2 channel cannot + /// Returns [`crate::Error::Connection`] if the underlying HTTP/2 channel cannot /// be shut down cleanly. pub fn close(self) -> Result<()> { Ok(self.client.close()?) @@ -345,7 +345,7 @@ impl GrpcConnectionAsync { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the HTTP/2 channel cannot be + /// Returns [`crate::Error::Connection`] if the HTTP/2 channel cannot be /// established (endpoint unreachable, TLS handshake failure). pub async fn connect(endpoint: &str, database_path: &str) -> Result { let config = GrpcConfig::new(endpoint).database(database_path); @@ -361,7 +361,7 @@ impl GrpcConnectionAsync { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the HTTP/2 channel cannot be + /// Returns [`crate::Error::Connection`] if the HTTP/2 channel cannot be /// established with the supplied configuration. pub async fn connect_with_config(config: GrpcConfig) -> Result { let database = config.database_path().map(std::string::ToString::to_string); @@ -374,7 +374,7 @@ impl GrpcConnectionAsync { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the server rejects the query or the + /// Returns [`crate::Error::Server`] if the server rejects the query or the /// HTTP/2 channel fails mid-stream. pub async fn execute_query_to_arrow(&mut self, sql: &str) -> Result { Ok(self.client.execute_query_to_arrow(sql).await?) @@ -384,7 +384,7 @@ impl GrpcConnectionAsync { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the server rejects the query or the + /// Returns [`crate::Error::Server`] if the server rejects the query or the /// HTTP/2 channel fails. pub async fn execute_query(&mut self, sql: &str) -> Result { Ok(self.client.execute_query(sql).await?) @@ -433,7 +433,7 @@ impl GrpcConnectionAsync { /// /// # Errors /// - /// Returns [`crate::Error::Client`] if the underlying HTTP/2 channel cannot + /// Returns [`crate::Error::Connection`] if the underlying HTTP/2 channel cannot /// be shut down cleanly. pub async fn close(self) -> Result<()> { Ok(self.client.close().await?) diff --git a/hyperdb-api/src/inserter.rs b/hyperdb-api/src/inserter.rs index 27ced1e..db06bf8 100644 --- a/hyperdb-api/src/inserter.rs +++ b/hyperdb-api/src/inserter.rs @@ -121,18 +121,18 @@ impl<'conn> Inserter<'conn> { /// /// - Returns [`Error::InvalidTableDefinition`] if `table_def` has zero /// columns. - /// - Returns [`Error::Other`] if `connection` is using gRPC transport + /// - Returns [`Error::FeatureNotSupported`] if `connection` is using gRPC transport /// (COPY is TCP-only). pub fn new(connection: &'conn Connection, table_def: &TableDefinition) -> Result { if table_def.column_count() == 0 { - return Err(Error::InvalidTableDefinition( - "Table definition must have at least one column".into(), + return Err(Error::invalid_table_definition( + "Table definition must have at least one column", )); } // Fail fast: verify the connection supports COPY (TCP only) if connection.tcp_client().is_none() { - return Err(Error::new( + return Err(Error::feature_not_supported( "Inserter requires a TCP connection. \ gRPC connections do not support COPY operations.", )); @@ -264,7 +264,7 @@ impl<'conn> Inserter<'conn> { /// /// - Returns an error if `target_table` fails to convert into a /// [`TableName`](crate::TableName). - /// - Returns [`Error::Client`] if creating the temporary staging table + /// - Returns [`Error::Server`] if creating the temporary staging table /// fails on the server. /// - Returns the errors from [`Inserter::new`] for the staging table /// (zero-column table definition, gRPC transport). @@ -302,7 +302,7 @@ impl<'conn> Inserter<'conn> { /// /// # Errors /// - /// Returns [`Error::Other`] if the current row already has all columns + /// Returns [`Error::InvalidTableDefinition`] if the current row already has all columns /// supplied, or if the current column is marked `NOT NULL` in the table /// definition. #[inline] @@ -314,7 +314,7 @@ impl<'conn> Inserter<'conn> { /// /// # Errors /// - /// Returns [`Error::Other`] with message `"Too many columns in row"` if + /// Returns [`Error::InvalidTableDefinition`] with message `"Too many columns in row"` if /// the current row already has all columns supplied. #[inline] pub fn add_bool(&mut self, value: bool) -> Result<()> { @@ -424,7 +424,7 @@ impl<'conn> Inserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if fewer (or more) columns were supplied + /// - Returns [`Error::InvalidTableDefinition`] if fewer (or more) columns were supplied /// than the table definition requires. /// - Returns any error from [`flush`](Self::flush) when an automatic /// flush is triggered by reaching the chunk byte/row limit. @@ -447,9 +447,9 @@ impl<'conn> Inserter<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if the connection is using gRPC transport + /// - Returns [`Error::FeatureNotSupported`] if the connection is using gRPC transport /// (COPY is TCP-only) and no COPY session exists yet. - /// - Returns [`Error::Client`] if the server rejects the `COPY IN` start + /// - Returns [`Error::Server`] if the server rejects the `COPY IN` start /// or the subsequent data send. /// - Returns [`Error::Io`] on transport-level I/O failures while writing /// the chunk. @@ -466,7 +466,9 @@ impl<'conn> Inserter<'conn> { // Ensure the COPY connection is started if self.writer.is_none() { let client = self.connection.tcp_client().ok_or_else(|| { - crate::Error::new("Inserter requires a TCP connection. gRPC connections do not support COPY operations.") + crate::Error::feature_not_supported( + "Inserter requires a TCP connection. gRPC connections do not support COPY operations.", + ) })?; let columns: Vec<&str> = self .table_def @@ -542,7 +544,7 @@ impl<'conn> Inserter<'conn> { pub fn add_row(&mut self, values: &[&dyn IntoValue]) -> Result<()> { let column_count = self.table_def.column_count(); if values.len() != column_count { - return Err(Error::new(format!( + return Err(Error::invalid_table_definition(format!( "Column count mismatch: expected {} columns but got {}", column_count, values.len() @@ -633,7 +635,7 @@ impl<'conn> Inserter<'conn> { .columns .get(column_index) .map_or("", |c| c.name.as_str()); - Error::new(format!( + Error::conversion(format!( "Cannot determine numeric precision for column '{col_name}' at index {column_index}. \ Ensure the column is defined with explicit SqlType including precision.\n\n\ Example fix:\n \ @@ -645,7 +647,7 @@ impl<'conn> Inserter<'conn> { // Small numeric: stored as i64 let unscaled = value.unscaled_value(); let narrowed = i64::try_from(unscaled).map_err(|_| { - Error::new(format!( + Error::conversion(format!( "Numeric value {unscaled} is out of range for i64 storage (precision {precision})" )) })?; @@ -674,7 +676,9 @@ impl<'conn> Inserter<'conn> { /// - Sending data fails pub fn execute(&mut self) -> Result { if self.chunk.column_index() != 0 { - return Err(Error::new("Incomplete row at execute time")); + return Err(Error::invalid_table_definition( + "Incomplete row at execute time", + )); } if self.row_count == 0 { @@ -684,7 +688,9 @@ impl<'conn> Inserter<'conn> { // Ensure COPY connection exists before proceeding when we have rows if self.writer.is_none() { let client = self.connection.tcp_client().ok_or_else(|| { - Error::new("Inserter requires a TCP connection. gRPC connections do not support COPY operations.") + Error::feature_not_supported( + "Inserter requires a TCP connection. gRPC connections do not support COPY operations.", + ) })?; let columns: Vec<&str> = self .table_def @@ -700,7 +706,7 @@ impl<'conn> Inserter<'conn> { let writer = self .writer .as_mut() - .ok_or_else(|| Error::new("Failed to initialize COPY connection for inserter"))?; + .ok_or_else(|| Error::internal("Failed to initialize COPY connection for inserter"))?; // If we have buffered data that hasn't been sent yet if !self.chunk.is_empty() { @@ -1279,10 +1285,10 @@ impl<'conn> MappedInserter<'conn> { /// /// - Returns the error from the inner [`Inserter::execute`] if writing /// the staging rows fails. - /// - Returns [`Error::Client`] if the `INSERT ... SELECT` from staging + /// - Returns [`Error::Server`] if the `INSERT ... SELECT` from staging /// to the target table is rejected (e.g. a mapping expression fails /// to evaluate). - /// - Returns [`Error::Client`] if dropping the staging table fails. + /// - Returns [`Error::Server`] if dropping the staging table fails. pub fn execute(&mut self) -> Result { let connection = self.inner.connection; let staging_table = self.staging_table.clone(); @@ -1501,17 +1507,19 @@ impl InsertChunk { /// /// # Errors /// - /// - Returns [`Error::Other`] with message `"Too many columns in row"` + /// - Returns [`Error::InvalidTableDefinition`] with message `"Too many columns in row"` /// if the current row already has all columns supplied. - /// - Returns [`Error::Other`] with message + /// - Returns [`Error::InvalidTableDefinition`] with message /// `"Cannot add NULL to non-nullable column"` if the current column /// is `NOT NULL` in the schema. pub fn add_null(&mut self) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } if !self.current_column_nullable() { - return Err(Error::new("Cannot add NULL to non-nullable column")); + return Err(Error::invalid_table_definition( + "Cannot add NULL to non-nullable column", + )); } self.ensure_header(); copy::write_null(&mut self.buffer); @@ -1523,11 +1531,11 @@ impl InsertChunk { /// /// # Errors /// - /// Returns [`Error::Other`] with message `"Too many columns in row"` if + /// Returns [`Error::InvalidTableDefinition`] with message `"Too many columns in row"` if /// the current row already has all columns supplied. pub fn add_bool(&mut self, value: bool) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); let int_value = i8::from(value); @@ -1547,7 +1555,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_i16(&mut self, value: i16) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); if self.current_column_nullable() { @@ -1566,7 +1574,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_i32(&mut self, value: i32) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); if self.current_column_nullable() { @@ -1585,7 +1593,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_i64(&mut self, value: i64) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); if self.current_column_nullable() { @@ -1604,7 +1612,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_f32(&mut self, value: f32) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); if self.current_column_nullable() { @@ -1623,7 +1631,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_f64(&mut self, value: f64) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); if self.current_column_nullable() { @@ -1651,10 +1659,10 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_bytes(&mut self, value: &[u8]) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } if value.len() > u32::MAX as usize { - return Err(Error::new(format!( + return Err(Error::conversion(format!( "Value length {} exceeds HyperBinary 4-byte length limit ({})", value.len(), u32::MAX @@ -1677,7 +1685,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_data128(&mut self, value: &[u8; 16]) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); if self.current_column_nullable() { @@ -1696,7 +1704,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_date(&mut self, value: Date) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); let julian_day = value.to_julian_day(); @@ -1716,7 +1724,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_time(&mut self, value: Time) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); let micros = value.to_microseconds(); @@ -1736,7 +1744,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_timestamp(&mut self, value: Timestamp) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); let micros = value.to_microseconds(); @@ -1756,7 +1764,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_offset_timestamp(&mut self, value: OffsetTimestamp) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); let micros = value.to_microseconds_utc(); @@ -1776,7 +1784,7 @@ impl InsertChunk { /// See [`add_bool`](Self::add_bool). pub fn add_interval(&mut self, value: Interval) -> Result<()> { if self.column_index >= self.column_count { - return Err(Error::new("Too many columns in row")); + return Err(Error::invalid_table_definition("Too many columns in row")); } self.ensure_header(); let packed = value.to_packed(); @@ -1795,11 +1803,11 @@ impl InsertChunk { /// /// # Errors /// - /// Returns [`Error::Other`] if fewer (or more) columns were supplied + /// Returns [`Error::InvalidTableDefinition`] if fewer (or more) columns were supplied /// for this row than the chunk's column count. pub fn end_row(&mut self) -> Result<()> { if self.column_index != self.column_count { - return Err(Error::new(format!( + return Err(Error::invalid_table_definition(format!( "Expected {} columns, got {}", self.column_count, self.column_index ))); @@ -1897,8 +1905,8 @@ impl<'conn> ChunkSender<'conn> { /// [`send_chunk`](Self::send_chunk), so transport errors surface there. pub fn new(connection: &'conn Connection, table_def: &TableDefinition) -> Result { if table_def.column_count() == 0 { - return Err(Error::InvalidTableDefinition( - "Table definition must have at least one column".into(), + return Err(Error::invalid_table_definition( + "Table definition must have at least one column", )); } @@ -1941,12 +1949,14 @@ impl<'conn> ChunkSender<'conn> { let mut writer_guard = self .writer .lock() - .map_err(|_| Error::new("ChunkSender mutex poisoned"))?; + .map_err(|_| Error::internal("ChunkSender mutex poisoned"))?; // Lazily initialize the COPY connection if writer_guard.is_none() { let client = self.connection.tcp_client().ok_or_else(|| { - Error::new("ChunkSender requires a TCP connection. gRPC connections do not support COPY operations.") + Error::feature_not_supported( + "ChunkSender requires a TCP connection. gRPC connections do not support COPY operations." + ) })?; let columns: Vec<&str> = self .columns @@ -2017,15 +2027,15 @@ impl<'conn> ChunkSender<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] with message `"ChunkSender mutex poisoned"` + /// - Returns [`Error::Internal`] with message `"ChunkSender mutex poisoned"` /// if a sender thread panicked while holding the writer lock. - /// - Returns [`Error::Client`] or [`Error::Io`] if sending the COPY + /// - Returns [`Error::Server`] or [`Error::Io`] if sending the COPY /// trailer or finishing the COPY operation fails. pub fn finish(self) -> Result { let mut writer_guard = self .writer .lock() - .map_err(|_| Error::new("ChunkSender mutex poisoned"))?; + .map_err(|_| Error::internal("ChunkSender mutex poisoned"))?; // If no chunks were sent, return 0 let Some(writer) = writer_guard.take() else { diff --git a/hyperdb-api/src/lib.rs b/hyperdb-api/src/lib.rs index a272282..e386109 100644 --- a/hyperdb-api/src/lib.rs +++ b/hyperdb-api/src/lib.rs @@ -185,12 +185,13 @@ pub use async_result::AsyncRowset; pub use catalog::Catalog; pub use connection::{Connection, CreateMode, ScalarValue}; pub use connection_builder::ConnectionBuilder; -pub use error::{Error, Result}; +pub use error::{ColumnErrorKind, Error, Result}; pub use params::ToSqlParam; pub use prepared::PreparedStatement; -// Re-export ErrorKind for matching on error categories, and Notice for callbacks +// Re-export Notice for callback registrants. ErrorKind is intentionally +// NOT re-exported — callers match directly on the flat `Error` enum. pub use async_transaction::AsyncTransaction; -pub use hyperdb_api_core::client::{ErrorKind, Notice, NoticeReceiver}; +pub use hyperdb_api_core::client::{Notice, NoticeReceiver}; pub use inserter::{ChunkSender, ColumnMapping, InsertChunk, Inserter, IntoValue, MappedInserter}; pub use names::{ escape_name, escape_sql_path, escape_string_literal, DatabaseName, Name, SchemaName, TableName, diff --git a/hyperdb-api/src/names.rs b/hyperdb-api/src/names.rs index 8fe3455..4e5ab71 100644 --- a/hyperdb-api/src/names.rs +++ b/hyperdb-api/src/names.rs @@ -48,7 +48,7 @@ pub(crate) const PG_IDENTIFIER_LIMIT: usize = 63; pub fn escape_name(name: &str) -> Result { let len = name.chars().count(); if len > PG_IDENTIFIER_LIMIT { - return Err(Error::InvalidName(format!( + return Err(Error::invalid_name(format!( "Name exceeds PostgreSQL identifier limit ({len} > {PG_IDENTIFIER_LIMIT})" ))); } @@ -146,7 +146,7 @@ impl Name { pub fn try_new(name: impl Into) -> Result { let unescaped = name.into(); if unescaped.is_empty() { - return Err(Error::InvalidName("Name must not be empty".into())); + return Err(Error::invalid_name("Name must not be empty")); } // escape_name validates the length limit and returns an error if exceeded let escaped = escape_name(&unescaped)?; @@ -504,7 +504,7 @@ impl FromStr for SchemaName { match parts.as_slice() { [s] => SchemaName::try_new(s), [d, s] => SchemaName::try_new(s)?.with_database(d), - _ => Err(Error::InvalidName(format!("Invalid SQL identifier: {s}"))), + _ => Err(Error::invalid_name(format!("Invalid SQL identifier: {s}"))), } } } @@ -726,7 +726,7 @@ impl FromStr for TableName { [t] => TableName::try_new(t), [s, t] => TableName::try_new(t)?.with_schema(s), [d, s, t] => TableName::try_new(t)?.with_schema(s)?.with_database(d), - _ => Err(Error::InvalidName(format!("Invalid SQL identifier: {s}"))), + _ => Err(Error::invalid_name(format!("Invalid SQL identifier: {s}"))), } } } diff --git a/hyperdb-api/src/pool.rs b/hyperdb-api/src/pool.rs index 13d909f..93b6158 100644 --- a/hyperdb-api/src/pool.rs +++ b/hyperdb-api/src/pool.rs @@ -23,7 +23,7 @@ //! let pool = create_pool(config)?; //! //! // Get a connection from the pool -//! let conn = pool.get().await.map_err(|e| hyperdb_api::Error::new(e.to_string()))?; +//! let conn = pool.get().await.map_err(|e| hyperdb_api::Error::internal(e.to_string()))?; //! //! // Use the connection //! conn.execute_command("SELECT 1").await?; @@ -341,7 +341,7 @@ pub type PooledConnection = managed::Object; /// /// # Errors /// -/// Returns [`Error::Other`] wrapping the `deadpool` builder failure if +/// Returns [`Error::Config`] wrapping the `deadpool` builder failure if /// the pool cannot be constructed (e.g. invalid `max_size`). Connections /// themselves are opened lazily on first use, so endpoint/auth errors /// surface from [`Pool::get`](managed::Pool::get), not here. @@ -351,7 +351,7 @@ pub fn create_pool(config: PoolConfig) -> Result { Pool::builder(manager) .max_size(max_size) .build() - .map_err(|e| Error::new(format!("Failed to create pool: {e}"))) + .map_err(|e| Error::config(format!("Failed to create pool: {e}"))) } #[cfg(test)] diff --git a/hyperdb-api/src/prepared.rs b/hyperdb-api/src/prepared.rs index 99d340f..1394705 100644 --- a/hyperdb-api/src/prepared.rs +++ b/hyperdb-api/src/prepared.rs @@ -96,9 +96,9 @@ impl<'conn> PreparedStatement<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] if the underlying [`Connection`] is on + /// - Returns [`Error::FeatureNotSupported`] if the underlying [`Connection`] is on /// gRPC transport (prepared statements are TCP-only). - /// - Returns [`Error::Client`] if the server rejects `Bind` or + /// - Returns [`Error::Server`] if the server rejects `Bind` or /// `Execute` (type mismatch, runtime error while streaming). /// - Returns [`Error::Io`] on transport-level I/O failures. pub fn query(&self, params: &[&dyn ToSqlParam]) -> Result> { @@ -117,8 +117,8 @@ impl<'conn> PreparedStatement<'conn> { /// /// # Errors /// - /// - Returns [`Error::Other`] on gRPC transport. - /// - Returns [`Error::Client`] if the server rejects `Bind` or + /// - Returns [`Error::FeatureNotSupported`] on gRPC transport. + /// - Returns [`Error::Server`] if the server rejects `Bind` or /// `Execute`. /// - Returns [`Error::Io`] on transport-level I/O failures. pub fn execute(&self, params: &[&dyn ToSqlParam]) -> Result { @@ -132,7 +132,7 @@ impl<'conn> PreparedStatement<'conn> { /// # Errors /// /// - Returns the error from [`query`](Self::query). - /// - Returns [`Error::Other`] with message `"Query returned no rows"` + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` /// if the result is empty. pub fn fetch_one(&self, params: &[&dyn ToSqlParam]) -> Result { self.query(params)?.require_first_row() @@ -163,9 +163,9 @@ impl<'conn> PreparedStatement<'conn> { /// # Errors /// /// - Returns the error from [`query`](Self::query). - /// - Returns [`Error::Other`] with message `"Query returned no rows"` + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` /// if the result is empty. - /// - Returns [`Error::Other`] with message `"Scalar query returned NULL"` + /// - Returns [`Error::Conversion`] with message `"Scalar query returned NULL"` /// if the first cell is SQL `NULL`. pub fn fetch_scalar(&self, params: &[&dyn ToSqlParam]) -> Result { self.query(params)?.require_scalar() @@ -197,7 +197,7 @@ pub(crate) fn encode_params(params: &[&dyn ToSqlParam]) -> Vec>> pub(crate) fn tcp_client(connection: &Connection) -> Result<&hyperdb_api_core::client::Client> { match connection.transport() { Transport::Tcp(tcp) => Ok(&tcp.client), - Transport::Grpc(_) => Err(Error::new( + Transport::Grpc(_) => Err(Error::feature_not_supported( "prepared statements are not supported over gRPC transport", )), } diff --git a/hyperdb-api/src/process.rs b/hyperdb-api/src/process.rs index 3cdb949..52d40b9 100644 --- a/hyperdb-api/src/process.rs +++ b/hyperdb-api/src/process.rs @@ -281,7 +281,7 @@ impl HyperProcess { } } } - return Err(Error::new( + return Err(Error::config( "HYPERD_PATH is not set. Point it at a hyperd executable, \ or run `make download-hyperd` (or `cargo run -p hyperd-bootstrap -- download`) \ to install a pinned release at `.hyperd/current/hyperd`.", @@ -301,7 +301,7 @@ impl HyperProcess { return Ok(without_exe); } } - return Err(Error::new(format!( + return Err(Error::config(format!( "HYPERD_PATH set to '{path_str}' but {HYPERD_EXE} not found in that directory" ))); } @@ -315,7 +315,7 @@ impl HyperProcess { return Ok(with_ext); } } - Err(Error::new(format!( + Err(Error::config(format!( "HYPERD_PATH set to '{}' but hyperd executable not found (checked: {})", path_str, path.display() @@ -326,7 +326,7 @@ impl HyperProcess { fn start_server(hyperd_path: &Path, parameters: Option<&Parameters>) -> Result { // Verify hyperd exists if !hyperd_path.exists() { - return Err(Error::new(format!( + return Err(Error::config(format!( "Hyper executable not found at: {}", hyperd_path.display() ))); @@ -341,17 +341,17 @@ impl HyperProcess { // Create callback listener on ephemeral port // This is the "dead man's switch" - when this connection is closed, Hyper shuts down let callback_listener = TcpListener::bind("127.0.0.1:0") - .map_err(|e| Error::new(format!("Failed to create callback listener: {e}")))?; + .map_err(|e| Error::internal(format!("Failed to create callback listener: {e}")))?; let callback_port = callback_listener .local_addr() - .map_err(|e| Error::new(format!("Failed to get callback port: {e}")))? + .map_err(|e| Error::internal(format!("Failed to get callback port: {e}")))? .port(); // Set a timeout for accepting the callback connection - callback_listener - .set_nonblocking(false) - .map_err(|e| Error::new(format!("Failed to set callback listener to blocking: {e}")))?; + callback_listener.set_nonblocking(false).map_err(|e| { + Error::internal(format!("Failed to set callback listener to blocking: {e}")) + })?; // Check if user wants to disable default parameters let use_defaults = parameters.map_or(true, |p| !p.contains_key(NO_DEFAULT_PARAMETERS)); @@ -383,8 +383,9 @@ impl HyperProcess { } else { // Create a temp directory for the socket let temp_dir = std::env::temp_dir().join(format!("hyper-{}", std::process::id())); - std::fs::create_dir_all(&temp_dir) - .map_err(|e| Error::new(format!("Failed to create socket directory: {e}")))?; + std::fs::create_dir_all(&temp_dir).map_err(|e| { + Error::internal(format!("Failed to create socket directory: {e}")) + })?; temp_dir }; Some(dir) @@ -609,7 +610,7 @@ impl HyperProcess { // Start the process let child = cmd.spawn().map_err(|e| { - Error::new(format!( + Error::internal(format!( "Failed to start Hyper server at {}: {}", hyperd_path.display(), e @@ -714,7 +715,7 @@ impl HyperProcess { // Poll for incoming connection with timeout let mut stream = loop { if start.elapsed() > timeout { - return Err(Error::new( + return Err(Error::internal( "Timeout waiting for Hyper to connect to callback listener. \ Hyper may have failed to start - check hyperd logs for details.", )); @@ -726,7 +727,7 @@ impl HyperProcess { thread::sleep(Duration::from_millis(50)); } Err(e) => { - return Err(Error::new(format!( + return Err(Error::internal(format!( "Failed to accept callback connection: {e}" ))); } @@ -734,9 +735,9 @@ impl HyperProcess { }; // Set stream back to blocking for reading - stream - .set_nonblocking(false) - .map_err(|e| Error::new(format!("Failed to set callback stream to blocking: {e}")))?; + stream.set_nonblocking(false).map_err(|e| { + Error::internal(format!("Failed to set callback stream to blocking: {e}")) + })?; // Set read timeout stream.set_read_timeout(Some(Duration::from_secs(10))).ok(); @@ -744,24 +745,24 @@ impl HyperProcess { // Read the endpoint descriptor from Hyper // Protocol: [1 byte length][N bytes descriptor string] let mut len_buf = [0u8; 1]; - stream - .read_exact(&mut len_buf) - .map_err(|e| Error::new(format!("Failed to read endpoint length from Hyper: {e}")))?; + stream.read_exact(&mut len_buf).map_err(|e| { + Error::internal(format!("Failed to read endpoint length from Hyper: {e}")) + })?; let len = len_buf[0] as usize; if len == 0 { - return Err(Error::new("Hyper sent empty endpoint descriptor")); + return Err(Error::internal("Hyper sent empty endpoint descriptor")); } let mut descriptor_buf = vec![0u8; len]; stream.read_exact(&mut descriptor_buf).map_err(|e| { - Error::new(format!( + Error::internal(format!( "Failed to read endpoint descriptor from Hyper: {e}" )) })?; let descriptor = String::from_utf8(descriptor_buf) - .map_err(|e| Error::new(format!("Invalid UTF-8 in endpoint descriptor: {e}")))?; + .map_err(|e| Error::internal(format!("Invalid UTF-8 in endpoint descriptor: {e}")))?; // Trim null bytes and whitespace that Hyper may include let descriptor = descriptor.trim_matches(|c: char| c == '\0' || c.is_whitespace()); @@ -826,7 +827,7 @@ impl HyperProcess { if without_prefix.contains(':') && !without_prefix.is_empty() { Ok(without_prefix.to_string()) } else { - Err(Error::new(format!( + Err(Error::internal(format!( "Invalid connection descriptor format: '{descriptor}'. Expected '://host:port' or 'tab.domain:///domain/'" ))) } @@ -866,7 +867,7 @@ impl HyperProcess { /// ``` pub fn require_endpoint(&self) -> crate::error::Result<&str> { self.endpoint().ok_or_else(|| { - crate::error::Error::new( + crate::error::Error::internal( "HyperProcess does not have a libpq endpoint (gRPC-only mode). \ Use grpc_endpoint() instead or start with LibPq or Both listen mode.", ) @@ -893,7 +894,7 @@ impl HyperProcess { /// Returns an error if this process was started in libpq-only mode. pub fn require_grpc_endpoint(&self) -> crate::error::Result<&str> { self.grpc_endpoint().ok_or_else(|| { - crate::error::Error::new( + crate::error::Error::internal( "HyperProcess does not have a gRPC endpoint (libpq-only mode). \ Use endpoint() instead or start with Grpc or Both listen mode.", ) @@ -1100,19 +1101,21 @@ impl HyperProcess { // Then force kill let _ = child.kill(); break child.wait().map_err(|e| { - Error::new(format!("Failed to wait for hyperd: {e}")) + Error::internal(format!("Failed to wait for hyperd: {e}")) }); } thread::sleep(Duration::from_millis(100)); } - Err(e) => break Err(Error::new(format!("Failed to wait for hyperd: {e}"))), + Err(e) => { + break Err(Error::internal(format!("Failed to wait for hyperd: {e}"))) + } } } } else { // Wait indefinitely child .wait() - .map_err(|e| Error::new(format!("Failed to wait for hyperd: {e}"))) + .map_err(|e| Error::internal(format!("Failed to wait for hyperd: {e}"))) }; wait_result?; diff --git a/hyperdb-api/src/result.rs b/hyperdb-api/src/result.rs index 7737796..1b6d77d 100644 --- a/hyperdb-api/src/result.rs +++ b/hyperdb-api/src/result.rs @@ -241,13 +241,13 @@ impl Row { /// /// # Errors /// - /// - Returns [`crate::Error::Other`] if `idx` is out of bounds for the row's + /// - Returns [`crate::Error::Conversion`] if `idx` is out of bounds for the row's /// column count. - /// - Returns [`crate::Error::Other`] if the cell is SQL `NULL` or its value + /// - Returns [`crate::Error::Conversion`] if the cell is SQL `NULL` or its value /// cannot be decoded as `T`. pub fn try_get(&self, idx: usize, column_name: &str) -> crate::error::Result { if idx >= self.column_count() { - return Err(crate::error::Error::new(format!( + return Err(crate::error::Error::conversion(format!( "Column index {} ({:?}) out of bounds — row has {} columns", idx, column_name, @@ -255,7 +255,7 @@ impl Row { ))); } self.get::(idx).ok_or_else(|| { - crate::error::Error::new(format!( + crate::error::Error::conversion(format!( "Column {idx} ({column_name:?}) is NULL or has incompatible type", )) }) @@ -734,7 +734,7 @@ impl RowValue for hyperdb_api_core::types::Numeric { /// impl FromRow for User { /// fn from_row(row: &Row) -> Result { /// Ok(User { -/// id: row.get::(0).ok_or_else(|| hyperdb_api::Error::new("NULL id"))?, +/// id: row.get::(0).ok_or_else(|| hyperdb_api::Error::conversion("NULL id"))?, /// name: row.get::(1).unwrap_or_default(), /// active: row.get::(2).unwrap_or(false), /// }) @@ -746,7 +746,7 @@ pub trait FromRow: Sized { /// /// # Errors /// - /// Returns an [`Error`](crate::Error) — typically [`crate::Error::Other`] — + /// Returns an [`Error`](crate::Error) — typically [`crate::Error::Conversion`] — /// when a required column is missing, SQL `NULL`, or cannot be /// decoded as the expected type. Implementations decide the exact /// failure shape. @@ -1155,10 +1155,10 @@ impl<'conn> Rowset<'conn> { /// /// # Errors /// - /// - Returns [`crate::Error::Client`] if the server sends an `ErrorResponse` + /// - Returns [`crate::Error::Server`] if the server sends an `ErrorResponse` /// while streaming the result set. /// - Returns [`crate::Error::Io`] on transport-level I/O failures. - /// - Returns [`crate::Error::Other`] if an Arrow IPC chunk cannot be decoded. + /// - Returns [`crate::Error::Conversion`] if an Arrow IPC chunk cannot be decoded. pub fn next_chunk(&mut self) -> Result>> { // Pull the next raw chunk from the underlying transport first; // on TCP, this is what makes the `RowDescription` bytes arrive @@ -1390,11 +1390,11 @@ impl<'conn> Rowset<'conn> { /// # Errors /// /// - Returns the error from [`first_row`](Self::first_row). - /// - Returns [`crate::Error::Other`] with message `"Query returned no rows"` + /// - Returns [`crate::Error::Conversion`] with message `"Query returned no rows"` /// if the result set is empty. pub fn require_first_row(self) -> crate::error::Result { self.first_row()? - .ok_or_else(|| crate::error::Error::new("Query returned no rows")) + .ok_or_else(|| crate::error::Error::conversion("Query returned no rows")) } /// Gets a scalar value from the first row, first column. @@ -1441,11 +1441,11 @@ impl<'conn> Rowset<'conn> { /// # Errors /// /// - Returns the error from [`scalar`](Self::scalar). - /// - Returns [`crate::Error::Other`] with message `"Scalar query returned NULL"` + /// - Returns [`crate::Error::Conversion`] with message `"Scalar query returned NULL"` /// if the single cell is SQL `NULL`. pub fn require_scalar(self) -> crate::error::Result { self.scalar()? - .ok_or_else(|| crate::error::Error::new("Scalar query returned NULL")) + .ok_or_else(|| crate::error::Error::conversion("Scalar query returned NULL")) } } diff --git a/hyperdb-api/src/table_definition.rs b/hyperdb-api/src/table_definition.rs index bbc1204..98dd684 100644 --- a/hyperdb-api/src/table_definition.rs +++ b/hyperdb-api/src/table_definition.rs @@ -762,12 +762,14 @@ impl TableDefinition { /// /// # Errors /// - /// Returns [`Error::Other`] with message + /// Returns [`Error::InvalidTableDefinition`] with message /// `"Table must have at least one column"` if this definition has no /// columns. pub fn to_create_sql(&self, fail_if_exists: bool) -> Result { if self.columns.is_empty() { - return Err(Error::new("Table must have at least one column")); + return Err(Error::invalid_table_definition( + "Table must have at least one column", + )); } let mut sql = String::new(); diff --git a/hyperdb-api/src/transport.rs b/hyperdb-api/src/transport.rs index 5c8b1cd..68d71d1 100644 --- a/hyperdb-api/src/transport.rs +++ b/hyperdb-api/src/transport.rs @@ -161,7 +161,7 @@ impl Transport { pub(crate) fn execute_command(&self, sql: &str) -> Result { match self { Transport::Tcp(tcp) => Ok(tcp.client.exec(sql)?), - Transport::Grpc(_) => Err(Error::new( + Transport::Grpc(_) => Err(Error::feature_not_supported( "gRPC transport is read-only. Write operations (INSERT, UPDATE, DELETE, DDL) \ are not yet supported over gRPC. Use a TCP connection for write operations.", )), diff --git a/hyperdb-api/tests/async_connection_tests.rs b/hyperdb-api/tests/async_connection_tests.rs index 8c679df..5cb86d3 100644 --- a/hyperdb-api/tests/async_connection_tests.rs +++ b/hyperdb-api/tests/async_connection_tests.rs @@ -124,7 +124,7 @@ impl FromRow for User { Ok(User { id: row .get::(0) - .ok_or_else(|| hyperdb_api::Error::new("NULL id"))?, + .ok_or_else(|| hyperdb_api::Error::conversion("NULL id"))?, name: row.get::(1), }) } diff --git a/hyperdb-api/tests/common/mod.rs b/hyperdb-api/tests/common/mod.rs index e033cff..4273bf3 100644 --- a/hyperdb-api/tests/common/mod.rs +++ b/hyperdb-api/tests/common/mod.rs @@ -113,7 +113,7 @@ impl TestConnection { pub(crate) fn execute_scalar_i32(&self, sql: &str) -> Result { self.connection .execute_scalar_query::(sql)? - .ok_or_else(|| hyperdb_api::Error::new(format!("NULL value for query: {sql}"))) + .ok_or_else(|| hyperdb_api::Error::conversion(format!("NULL value for query: {sql}"))) } /// Executes a scalar query and returns a single i64 value. @@ -124,7 +124,7 @@ impl TestConnection { pub(crate) fn execute_scalar_i64(&self, sql: &str) -> Result { self.connection .execute_scalar_query::(sql)? - .ok_or_else(|| hyperdb_api::Error::new(format!("NULL value for query: {sql}"))) + .ok_or_else(|| hyperdb_api::Error::conversion(format!("NULL value for query: {sql}"))) } /// Executes a scalar query and returns a single String value. @@ -135,7 +135,7 @@ impl TestConnection { pub(crate) fn execute_scalar_string(&self, sql: &str) -> Result { self.connection .execute_scalar_query::(sql)? - .ok_or_else(|| hyperdb_api::Error::new(format!("NULL value for query: {sql}"))) + .ok_or_else(|| hyperdb_api::Error::conversion(format!("NULL value for query: {sql}"))) } /// Executes a scalar query and returns a single bool value. @@ -146,7 +146,7 @@ impl TestConnection { pub(crate) fn execute_scalar_bool(&self, sql: &str) -> Result { self.connection .execute_scalar_query::(sql)? - .ok_or_else(|| hyperdb_api::Error::new(format!("NULL value for query: {sql}"))) + .ok_or_else(|| hyperdb_api::Error::conversion(format!("NULL value for query: {sql}"))) } /// Counts the number of tuples in a table. diff --git a/hyperdb-api/tests/remaining_features_tests.rs b/hyperdb-api/tests/remaining_features_tests.rs index ca55754..45fdf95 100644 --- a/hyperdb-api/tests/remaining_features_tests.rs +++ b/hyperdb-api/tests/remaining_features_tests.rs @@ -253,7 +253,7 @@ impl FromRow for TestUser { Ok(TestUser { id: row .get::(0) - .ok_or_else(|| hyperdb_api::Error::new("NULL id"))?, + .ok_or_else(|| hyperdb_api::Error::conversion("NULL id"))?, name: row.get::(1).unwrap_or_default(), score: row.get::(2).unwrap_or(0.0), }) diff --git a/hyperdb-bootstrap/src/download.rs b/hyperdb-bootstrap/src/download.rs index bbf326f..0a83add 100644 --- a/hyperdb-bootstrap/src/download.rs +++ b/hyperdb-bootstrap/src/download.rs @@ -53,26 +53,17 @@ pub fn download_and_verify( .arg(dest) .arg(url) .status() - .map_err(|source| Error::Io { - context: "spawning curl".to_string(), - source, - })?; + .map_err(|source| Error::io("spawning curl", source))?; if !status.success() { - return Err(Error::CurlFailed { - url: url.to_string(), - code: status.code().unwrap_or(-1), - }); + return Err(Error::curl_failed(url, status.code().unwrap_or(-1))); } let actual = hash_file(dest)?; match expected_sha256 { Some(expected) => { if !actual.eq_ignore_ascii_case(expected) { - return Err(Error::ChecksumMismatch { - expected: expected.to_string(), - actual, - }); + return Err(Error::checksum_mismatch(expected, actual)); } tracing::info!(sha256 = %actual, "sha256 verified"); } @@ -91,17 +82,14 @@ pub fn download_and_verify( reason = "readable hex/string formatting loop; refactoring to fold! obscures intent" )] fn hash_file(path: &Path) -> Result { - let mut file = File::open(path).map_err(|source| Error::Io { - context: format!("opening {} for hashing", path.display()), - source, - })?; + let mut file = File::open(path) + .map_err(|source| Error::io(format!("opening {} for hashing", path.display()), source))?; let mut hasher = Sha256::new(); let mut buf = vec![0u8; HASH_CHUNK]; loop { - let n = file.read(&mut buf).map_err(|source| Error::Io { - context: format!("reading {}", path.display()), - source, - })?; + let n = file + .read(&mut buf) + .map_err(|source| Error::io(format!("reading {}", path.display()), source))?; if n == 0 { break; } diff --git a/hyperdb-bootstrap/src/error.rs b/hyperdb-bootstrap/src/error.rs index 3ad4cb0..d8a5055 100644 --- a/hyperdb-bootstrap/src/error.rs +++ b/hyperdb-bootstrap/src/error.rs @@ -83,3 +83,50 @@ pub enum Error { #[error("failed to scrape latest release: {0}")] ScrapeFailed(&'static str), } + +impl Error { + /// Constructs an [`Self::UnsupportedPlatform`] error. + pub fn unsupported_platform(os: impl Into, arch: impl Into) -> Self { + Error::UnsupportedPlatform { + os: os.into(), + arch: arch.into(), + } + } + + /// Constructs an [`Self::UnknownPlatformSlug`] error. + pub fn unknown_platform_slug(slug: impl Into) -> Self { + Error::UnknownPlatformSlug(slug.into()) + } + + /// Constructs an [`Self::Io`] error. + pub fn io(context: impl Into, source: std::io::Error) -> Self { + Error::Io { + context: context.into(), + source, + } + } + + /// Constructs an [`Self::HttpStatus`] error. + pub fn http_status(url: impl Into, status: u16) -> Self { + Error::HttpStatus { + url: url.into(), + status, + } + } + + /// Constructs an [`Self::CurlFailed`] error. + pub fn curl_failed(url: impl Into, code: i32) -> Self { + Error::CurlFailed { + url: url.into(), + code, + } + } + + /// Constructs an [`Self::ChecksumMismatch`] error. + pub fn checksum_mismatch(expected: impl Into, actual: impl Into) -> Self { + Error::ChecksumMismatch { + expected: expected.into(), + actual: actual.into(), + } + } +} diff --git a/hyperdb-bootstrap/src/extract.rs b/hyperdb-bootstrap/src/extract.rs index edf3b3e..3fc10a2 100644 --- a/hyperdb-bootstrap/src/extract.rs +++ b/hyperdb-bootstrap/src/extract.rs @@ -26,16 +26,12 @@ use crate::Error; /// archive cannot be opened or parsed, and [`Error::HyperdNotInArchive`] /// if the archive does not contain a `hyperd` / `hyperd.exe` entry. pub fn extract_hyperd(zip_path: &Path, dest_dir: &Path) -> Result, Error> { - let file = File::open(zip_path).map_err(|source| Error::Io { - context: format!("opening zip {}", zip_path.display()), - source, - })?; + let file = File::open(zip_path) + .map_err(|source| Error::io(format!("opening zip {}", zip_path.display()), source))?; let mut archive = zip::ZipArchive::new(file).map_err(Error::Zip)?; - fs::create_dir_all(dest_dir).map_err(|source| Error::Io { - context: format!("creating {}", dest_dir.display()), - source, - })?; + fs::create_dir_all(dest_dir) + .map_err(|source| Error::io(format!("creating {}", dest_dir.display()), source))?; let mut extracted = Vec::new(); let mut found_hyperd = false; @@ -54,26 +50,18 @@ pub fn extract_hyperd(zip_path: &Path, dest_dir: &Path) -> Result, let out_path = dest_dir.join(&rel); if entry.is_dir() { - fs::create_dir_all(&out_path).map_err(|source| Error::Io { - context: format!("creating {}", out_path.display()), - source, - })?; + fs::create_dir_all(&out_path) + .map_err(|source| Error::io(format!("creating {}", out_path.display()), source))?; continue; } if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent).map_err(|source| Error::Io { - context: format!("creating {}", parent.display()), - source, - })?; + fs::create_dir_all(parent) + .map_err(|source| Error::io(format!("creating {}", parent.display()), source))?; } - let mut out = File::create(&out_path).map_err(|source| Error::Io { - context: format!("creating {}", out_path.display()), - source, - })?; - io::copy(&mut entry, &mut out).map_err(|source| Error::Io { - context: format!("writing {}", out_path.display()), - source, - })?; + let mut out = File::create(&out_path) + .map_err(|source| Error::io(format!("creating {}", out_path.display()), source))?; + io::copy(&mut entry, &mut out) + .map_err(|source| Error::io(format!("writing {}", out_path.display()), source))?; #[cfg(unix)] { diff --git a/hyperdb-bootstrap/src/install.rs b/hyperdb-bootstrap/src/install.rs index fa3f452..3f77616 100644 --- a/hyperdb-bootstrap/src/install.rs +++ b/hyperdb-bootstrap/src/install.rs @@ -146,21 +146,14 @@ fn download_and_extract( versioned_dir: &Path, ) -> Result<(), Error> { if versioned_dir.exists() { - fs::remove_dir_all(versioned_dir).map_err(|source| Error::Io { - context: format!("clearing {}", versioned_dir.display()), - source, - })?; + fs::remove_dir_all(versioned_dir) + .map_err(|source| Error::io(format!("clearing {}", versioned_dir.display()), source))?; } - fs::create_dir_all(versioned_dir).map_err(|source| Error::Io { - context: format!("creating {}", versioned_dir.display()), - source, - })?; + fs::create_dir_all(versioned_dir) + .map_err(|source| Error::io(format!("creating {}", versioned_dir.display()), source))?; let url = build_download_url(release, platform); - let tmp = tempfile::tempdir().map_err(|source| Error::Io { - context: "creating temp dir".to_string(), - source, - })?; + let tmp = tempfile::tempdir().map_err(|source| Error::io("creating temp dir", source))?; let zip_path = tmp.path().join("hyperapi-cxx.zip"); download_and_verify(&url, release.sha256_for(platform), &zip_path)?; extract_hyperd(&zip_path, versioned_dir)?; @@ -171,48 +164,38 @@ fn refresh_current(current: &Path, source: &Path, version_tag: &str) -> Result<( // current/ is a fresh file copy every run — avoids Windows symlink // privileges and keeps the Makefile auto-discovery path stable. if current.exists() { - fs::remove_dir_all(current).map_err(|source| Error::Io { - context: format!("clearing {}", current.display()), - source, - })?; + fs::remove_dir_all(current) + .map_err(|source| Error::io(format!("clearing {}", current.display()), source))?; } - fs::create_dir_all(current).map_err(|source| Error::Io { - context: format!("creating {}", current.display()), - source, - })?; + fs::create_dir_all(current) + .map_err(|source| Error::io(format!("creating {}", current.display()), source))?; copy_dir_contents(source, current)?; - fs::write(current.join("VERSION"), version_tag).map_err(|source| Error::Io { - context: format!("writing {}/VERSION", current.display()), - source, - })?; + fs::write(current.join("VERSION"), version_tag) + .map_err(|source| Error::io(format!("writing {}/VERSION", current.display()), source))?; Ok(()) } fn copy_dir_contents(from: &Path, to: &Path) -> Result<(), Error> { - for entry in fs::read_dir(from).map_err(|source| Error::Io { - context: format!("reading {}", from.display()), - source, - })? { - let entry = entry.map_err(|source| Error::Io { - context: format!("reading {}", from.display()), - source, - })?; + for entry in fs::read_dir(from) + .map_err(|source| Error::io(format!("reading {}", from.display()), source))? + { + let entry = + entry.map_err(|source| Error::io(format!("reading {}", from.display()), source))?; let src = entry.path(); let dst = to.join(entry.file_name()); - let ty = entry.file_type().map_err(|source| Error::Io { - context: format!("stat {}", src.display()), - source, - })?; + let ty = entry + .file_type() + .map_err(|source| Error::io(format!("stat {}", src.display()), source))?; if ty.is_dir() { - fs::create_dir_all(&dst).map_err(|source| Error::Io { - context: format!("creating {}", dst.display()), - source, - })?; + fs::create_dir_all(&dst) + .map_err(|source| Error::io(format!("creating {}", dst.display()), source))?; copy_dir_contents(&src, &dst)?; } else { - fs::copy(&src, &dst).map_err(|source| Error::Io { - context: format!("copying {} -> {}", src.display(), dst.display()), - source, + fs::copy(&src, &dst).map_err(|source| { + Error::io( + format!("copying {} -> {}", src.display(), dst.display()), + source, + ) })?; #[cfg(unix)] { diff --git a/hyperdb-bootstrap/src/platform.rs b/hyperdb-bootstrap/src/platform.rs index de68c4e..6ca3d93 100644 --- a/hyperdb-bootstrap/src/platform.rs +++ b/hyperdb-bootstrap/src/platform.rs @@ -49,10 +49,7 @@ impl Platform { ("macos", "x86_64") => Ok(Self::MacosX86_64), ("linux", "x86_64") => Ok(Self::LinuxX86_64), ("windows", "x86_64") => Ok(Self::WindowsX86_64), - _ => Err(Error::UnsupportedPlatform { - os: os.to_string(), - arch: arch.to_string(), - }), + _ => Err(Error::unsupported_platform(os, arch)), } } @@ -94,7 +91,7 @@ impl std::str::FromStr for Platform { "macos-x86_64" => Ok(Self::MacosX86_64), "linux-x86_64" => Ok(Self::LinuxX86_64), "windows-x86_64" => Ok(Self::WindowsX86_64), - other => Err(Error::UnknownPlatformSlug(other.to_string())), + other => Err(Error::unknown_platform_slug(other)), } } } diff --git a/hyperdb-bootstrap/src/release.rs b/hyperdb-bootstrap/src/release.rs index 1c78e5f..3a8ce10 100644 --- a/hyperdb-bootstrap/src/release.rs +++ b/hyperdb-bootstrap/src/release.rs @@ -67,9 +67,8 @@ impl PinnedRelease { /// Returns [`Error::Io`] if the file cannot be read, or /// [`Error::TomlParse`] if the content is not a valid `PinnedRelease`. pub fn from_toml_file(path: &Path) -> Result { - let text = std::fs::read_to_string(path).map_err(|source| Error::Io { - context: format!("reading version file {}", path.display()), - source, + let text = std::fs::read_to_string(path).map_err(|source| { + Error::io(format!("reading version file {}", path.display()), source) })?; Self::from_toml_str(&text) } diff --git a/hyperdb-bootstrap/src/scrape.rs b/hyperdb-bootstrap/src/scrape.rs index c81e560..98419a3 100644 --- a/hyperdb-bootstrap/src/scrape.rs +++ b/hyperdb-bootstrap/src/scrape.rs @@ -39,10 +39,7 @@ pub fn scrape_latest(platform: Platform) -> Result { .map_err(Error::Http)?; let resp = client.get(RELEASES_URL).send().map_err(Error::Http)?; if !resp.status().is_success() { - return Err(Error::HttpStatus { - url: RELEASES_URL.to_string(), - status: resp.status().as_u16(), - }); + return Err(Error::http_status(RELEASES_URL, resp.status().as_u16())); } let html = resp.text().map_err(Error::Http)?; parse_latest(&html, platform) diff --git a/hyperdb-mcp/src/engine.rs b/hyperdb-mcp/src/engine.rs index 8cc23e7..dc7b9a3 100644 --- a/hyperdb-mcp/src/engine.rs +++ b/hyperdb-mcp/src/engine.rs @@ -445,12 +445,7 @@ impl Engine { } self.hyper .as_ref() - .ok_or_else(|| { - McpError::new( - ErrorCode::InternalError, - "no hyperd endpoint available".to_string(), - ) - })? + .ok_or_else(|| McpError::new(ErrorCode::InternalError, "no hyperd endpoint available"))? .require_endpoint() .map(std::string::ToString::to_string) .map_err(|e| McpError::new(ErrorCode::InternalError, e.to_string())) diff --git a/hyperdb-mcp/src/error.rs b/hyperdb-mcp/src/error.rs index 060c7dc..cb67e9e 100644 --- a/hyperdb-mcp/src/error.rs +++ b/hyperdb-mcp/src/error.rs @@ -119,50 +119,103 @@ fn default_suggestion(code: ErrorCode, _message: &str) -> Option { } } -/// Converts a `hyperdb_api::Error` into an [`McpError`] by sniffing the message text -/// to pick the most specific error code. Falls back to [`ErrorCode::InternalError`]. +/// Converts a `hyperdb_api::Error` into an [`McpError`] by inspecting +/// the structured variant first, falling back to message-substring +/// classification for variants whose payload is just a `String`. impl From for McpError { fn from(err: hyperdb_api::Error) -> Self { + // Structured variants get classified by their type, not their + // message. SQLSTATE-bearing server errors are routed by + // SQLSTATE code directly — no string sniffing. + if let hyperdb_api::Error::Server { + sqlstate: Some(ref code), + .. + } = err + { + match code.as_str() { + "22003" => { + // numeric_value_out_of_range + return McpError::new(ErrorCode::SchemaMismatch, err.to_string()).with_suggestion( + "A numeric value exceeded its column's range. Retry with a partial schema override that widens the offending column, e.g. schema: {\"Population\": \"BIGINT\"} or {\"Amount\": \"NUMERIC(38,0)\"}. The override is a partial dictionary keyed by column name — unlisted columns keep their inferred type. Call inspect_file first if you don't know which column is too narrow."); + } + "22P02" => { + // invalid_text_representation + return McpError::new(ErrorCode::SchemaMismatch, err.to_string()).with_suggestion( + "A value could not be parsed into its column type. Retry with a partial schema override forcing TEXT for the offending column, e.g. schema: {\"Id\": \"TEXT\"}, and cast in SQL as needed."); + } + "0A000" => { + // feature_not_supported — Hyper's "Multi-part queries" + return McpError::new(ErrorCode::SqlError, err.to_string()).with_suggestion( + "Hyper only accepts one SQL statement per call. Split your query into separate execute/query calls — one per statement."); + } + _ => {} // fall through to message-based classification + } + } + + // Connection-lost / transport-desync detection — these may + // arrive as Connection, Closed, or Internal variants depending + // on where they originate; sniff the message string. let msg = err.to_string(); let lower = msg.to_lowercase(); if is_connection_lost(&msg) { - McpError::new(ErrorCode::ConnectionLost, msg) - } else if msg.contains("Multi-part queries") || msg.contains("0A000") { - // Hyper only allows one statement per call. Rewrite the error - // with an actionable suggestion instead of the cryptic code. - McpError::new(ErrorCode::SqlError, msg) - .with_suggestion("Hyper only accepts one SQL statement per call. Split your query into separate execute/query calls — one per statement.") - } else if msg.contains("22003") - || lower.contains("numeric overflow") - || lower.contains("out of range") - { - // SQLSTATE 22003: numeric_value_out_of_range. Happens at COPY/INSERT - // time when a value exceeds its column type. With the partial - // name-keyed schema override now in place, widening a single column - // is a one-line retry. - McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion( - "A numeric value exceeded its column's range. Retry with a partial schema override that widens the offending column, e.g. schema: {\"Population\": \"BIGINT\"} or {\"Amount\": \"NUMERIC(38,0)\"}. The override is a partial dictionary keyed by column name — unlisted columns keep their inferred type. Call inspect_file first if you don't know which column is too narrow.") - } else if msg.contains("22P02") || lower.contains("invalid input syntax") { - // SQLSTATE 22P02: invalid_text_representation. A value couldn't be - // parsed into its column type (e.g. non-date string in a DATE - // column). Usually the safe fix is to keep the column as TEXT and - // cast later in SQL. - McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion( - "A value could not be parsed into its column type. Retry with a partial schema override forcing TEXT for the offending column, e.g. schema: {\"Id\": \"TEXT\"}, and cast in SQL as needed.") - } else if msg.contains("syntax error") - || (msg.contains("does not exist") && msg.contains("column")) - { - McpError::new(ErrorCode::SqlError, msg) - } else if msg.contains("No such file") || msg.contains("not found") { - McpError::new(ErrorCode::FileNotFound, msg) - } else if is_resource_busy(&msg) { - // ATTACH on a .hyper file already held by another hyperd - // surfaces as a "database is in use" / "already attached" / - // "could not lock" error. Route to ResourceBusy so the LLM - // sees an actionable recovery hint instead of InternalError. - McpError::new(ErrorCode::ResourceBusy, msg) - } else { - McpError::new(ErrorCode::InternalError, msg) + return McpError::new(ErrorCode::ConnectionLost, msg); + } + + // Resource-busy is a hyperd attach-time error; same multi-source + // problem as connection-lost. + if is_resource_busy(&msg) { + return McpError::new(ErrorCode::ResourceBusy, msg); + } + + // Variant-driven classification for the remaining cases. + match err { + // File-not-found errors come back as NotFound or as a Server + // error containing the phrase; check both. + hyperdb_api::Error::NotFound(_) => McpError::new(ErrorCode::FileNotFound, msg), + + // Server errors without a SQLSTATE we recognize fall through + // to the substring fallback below. + hyperdb_api::Error::Server { .. } => { + // Legacy substring fallback — covers messages whose + // SQLSTATE was carried in the text (older hyperd + // versions) rather than the structured field. + if msg.contains("22003") + || lower.contains("numeric overflow") + || lower.contains("out of range") + { + McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion( + "A numeric value exceeded its column's range. Retry with a partial schema override that widens the offending column, e.g. schema: {\"Population\": \"BIGINT\"} or {\"Amount\": \"NUMERIC(38,0)\"}. The override is a partial dictionary keyed by column name — unlisted columns keep their inferred type. Call inspect_file first if you don't know which column is too narrow.") + } else if msg.contains("22P02") || lower.contains("invalid input syntax") { + McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion( + "A value could not be parsed into its column type. Retry with a partial schema override forcing TEXT for the offending column, e.g. schema: {\"Id\": \"TEXT\"}, and cast in SQL as needed.") + } else if msg.contains("syntax error") + || (msg.contains("does not exist") && msg.contains("column")) + { + McpError::new(ErrorCode::SqlError, msg) + } else if msg.contains("No such file") || msg.contains("not found") { + McpError::new(ErrorCode::FileNotFound, msg) + } else { + McpError::new(ErrorCode::SqlError, msg) + } + } + + // Conversion errors are usually decode failures from + // result-row processing; map to InternalError until we + // surface them more specifically. + hyperdb_api::Error::Conversion(_) => McpError::new(ErrorCode::InternalError, msg), + + // Configuration errors are caller-visible setup mistakes. + hyperdb_api::Error::Config(_) => McpError::new(ErrorCode::InvalidArgument, msg), + + // Connection / Closed / Timeout — surface as ConnectionLost + // so the engine recycles. is_connection_lost above already + // catches most of these via message; this is a fallback. + hyperdb_api::Error::Connection { .. } + | hyperdb_api::Error::Closed(_) + | hyperdb_api::Error::Timeout(_) + | hyperdb_api::Error::Cancelled(_) => McpError::new(ErrorCode::ConnectionLost, msg), + + _ => McpError::new(ErrorCode::InternalError, msg), } } } diff --git a/hyperdb-mcp/tests/error_tests.rs b/hyperdb-mcp/tests/error_tests.rs index 4956872..7f0d5f7 100644 --- a/hyperdb-mcp/tests/error_tests.rs +++ b/hyperdb-mcp/tests/error_tests.rs @@ -110,7 +110,8 @@ fn does_not_classify_unrelated_errors_as_connection_lost() { /// to read wire-level messages and was the reason we built `inspect_file`. #[test] fn maps_22003_to_schema_mismatch_with_override_suggestion() { - let upstream = hyperdb_api::Error::new("ERROR: numeric overflow (SQLSTATE 22003)"); + let upstream = + hyperdb_api::Error::server(Some("22003".to_string()), "numeric overflow", None, None); let mcp: McpError = upstream.into(); assert_eq!(mcp.code, ErrorCode::SchemaMismatch); let suggestion = mcp.suggestion.expect("22003 must have a suggestion"); @@ -130,7 +131,8 @@ fn maps_22003_to_schema_mismatch_with_override_suggestion() { /// differently. #[test] fn maps_out_of_range_phrase_to_schema_mismatch() { - let upstream = hyperdb_api::Error::new("value out of range for type integer"); + let upstream = + hyperdb_api::Error::server(None, "value out of range for type integer", None, None); let mcp: McpError = upstream.into(); assert_eq!(mcp.code, ErrorCode::SchemaMismatch); assert!(mcp.suggestion.is_some()); @@ -142,8 +144,12 @@ fn maps_out_of_range_phrase_to_schema_mismatch() { /// and casting in SQL rather than guessing a new type. #[test] fn maps_22p02_to_schema_mismatch_with_text_suggestion() { - let upstream = - hyperdb_api::Error::new("ERROR: invalid input syntax for type date (SQLSTATE 22P02)"); + let upstream = hyperdb_api::Error::server( + Some("22P02".to_string()), + "invalid input syntax for type date", + None, + None, + ); let mcp: McpError = upstream.into(); assert_eq!(mcp.code, ErrorCode::SchemaMismatch); let suggestion = mcp.suggestion.expect("22P02 must have a suggestion");