Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8419569
feat(ingest): add `hotdata ingest` command group
eddietejeda Jul 8, 2026
7f0d0a4
fix(ingest): redact source secrets from --debug request logging
eddietejeda Jul 8, 2026
7a8ad71
refactor(ingest): mirror the `connections` UX — new/list/create/impor…
eddietejeda Jul 8, 2026
e48211a
fix(ingest): actionable hint on enqueue transport failures
eddietejeda Jul 8, 2026
e6a3f9f
fix(workspace): report the workspace commands actually target
eddietejeda Jul 8, 2026
af3b779
refactor(ingest): drop display-side connector dedup (fixed at the sou…
eddietejeda Jul 8, 2026
f961646
feat(ingest): add-connection discovers schema only, loads no data
eddietejeda Jul 8, 2026
af5445f
feat(ingest): show live stage progress while polling
eddietejeda Jul 8, 2026
57e7b6d
refactor(ingest): consolidate add-connection under `new`; drop `create`
eddietejeda Jul 8, 2026
0181283
docs(ingest): terse one-line command summaries in help
eddietejeda Jul 8, 2026
a408dbc
refactor(ingest): rename `refresh` to `update`
eddietejeda Jul 8, 2026
32d8bfb
feat(ingest): mark active connectors in `connectors`
eddietejeda Jul 8, 2026
e8d0119
feat(ingest): connection/import command surface, API-backed listings,…
eddietejeda Jul 8, 2026
6911cbc
feat(ingest): imports return immediately; add `status` for tracking
eddietejeda Jul 8, 2026
96795f9
feat(ingest): color the STATUS column in list-imports/list-connections
eddietejeda Jul 8, 2026
2041188
feat(ingest): IMPORT ID is the leftmost list-imports column
eddietejeda Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::commands::context::ContextCommands;
use crate::commands::databases::DatabasesCommands;
use crate::commands::embedding_providers::EmbeddingProvidersCommands;
use crate::commands::indexes::IndexesCommands;
use crate::commands::ingest::IngestCommands;
use crate::commands::jobs::JobsCommands;
use crate::commands::queries::QueriesCommands;
use crate::commands::query::QueryCommands;
Expand Down Expand Up @@ -132,6 +133,21 @@ pub enum Commands {
command: Option<JobsCommands>,
},

/// Ingest data from external connectors (databases, REST APIs, files, Iceberg)
/// into a managed database via the hotdata ingest service
Ingest {
/// Workspace ID (defaults to first workspace from login)
#[arg(long, short = 'w', global = true)]
workspace_id: Option<String>,

/// Output format
#[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"], global = true)]
output: String,

#[command(subcommand)]
command: IngestCommands,
},

/// Manage indexes on a table
Indexes {
/// Workspace ID (defaults to first workspace from login)
Expand Down
1 change: 1 addition & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod credentials;
pub mod database_session;
pub mod ingest;
pub mod jwt;
pub mod raw_http;
pub mod sdk;
126 changes: 126 additions & 0 deletions src/client/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,45 @@ pub fn check_status(profile_config: &config::ProfileConfig) -> AuthStatus {
}
}

/// The workspace a command with no `--workspace-id` targets for this profile —
/// the single source of truth shared by `main`'s `resolve_workspace` and
/// `auth status`, so the status readout can never disagree with where commands
/// actually run.
///
/// An api-key credential scoped to exactly one workspace (a database API token)
/// pins that workspace. For a multi-workspace or unrestricted api key we honor
/// the saved default (`workspaces set` moves a workspace to the front of the
/// config list) when the key can reach it, otherwise fall back to the
/// credential's own first authorized workspace — never a workspace the gateway
/// would reject. A CLI session uses the saved default. `None` means no default
/// is known and the caller must pass `--workspace-id`.
pub(crate) fn default_workspace_id(profile_config: &config::ProfileConfig) -> Option<String> {
let saved_default = || {
profile_config
.workspaces
.first()
.map(|w| w.public_id.clone())
};
if !matches!(
profile_config.api_key_source,
ApiKeySource::Flag | ApiKeySource::Env
) {
return saved_default();
}
let ids = api_key_workspace_ids(profile_config);
if let [only] = ids.as_slice() {
return Some(only.clone());
}
// Multi/unrestricted key: prefer the saved default when the key authorizes
// it (empty `ids` = unrestricted, reaches everything), else the key's first.
if let Some(first) = saved_default()
&& (ids.is_empty() || ids.contains(&first))
{
return Some(first);
}
ids.into_iter().next()
}

/// Workspace public-ids the active api-key credential (`--api-key` /
/// `HOTDATA_API_KEY`) is scoped to, read from its minted JWT's `workspaces`
/// claim. A database API token carries exactly one. Empty when there's no api
Expand Down Expand Up @@ -212,6 +251,93 @@ mod tests {
assert_eq!(ids, vec!["workbound".to_string()]);
}

// --- default_workspace_id tests ---

fn ws(id: &str) -> config::WorkspaceEntry {
config::WorkspaceEntry {
public_id: id.into(),
name: id.into(),
}
}

/// Mint mock returning a JWT whose `workspaces` claim is `ids`.
fn mock_token_with_workspaces(server: &mut mockito::Server, ids: &[&str]) -> mockito::Mock {
let claim = serde_json::json!({ "workspaces": ids }).to_string();
let jwt = format!("header.{}.sig", URL_SAFE_NO_PAD.encode(claim.as_bytes()));
server
.mock("POST", "/o/token/")
.match_body(mockito::Matcher::UrlEncoded(
"grant_type".into(),
"api_token".into(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
r#"{{"access_token":"{jwt}","expires_in":300,"refresh_token":"r"}}"#
))
.create()
}

#[test]
fn default_workspace_id_session_uses_saved_default_without_network() {
// Config source (a CLI session): the saved default, no mint call.
let (_tmp, _guard) = with_temp_config_dir();
let profile = ProfileConfig {
workspaces: vec![ws("work_saved"), ws("work_other")],
..Default::default() // api_key_source defaults to Config
};
assert_eq!(
default_workspace_id(&profile),
Some("work_saved".to_string())
);
}

#[test]
fn default_workspace_id_single_workspace_token_pins_its_own() {
// A database token authorizes exactly one workspace — use it even when a
// different workspace sits at the front of the (unrelated) config cache.
let (_tmp, _guard) = with_temp_config_dir();
let mut server = mockito::Server::new();
let mint = mock_token_with_workspaces(&mut server, &["work_only"]);
let mut profile = mock_profile(&server.url(), Some("hd_dbtoken"));
profile.api_key_source = ApiKeySource::Env;
profile.workspaces = vec![ws("work_saved")];
assert_eq!(
default_workspace_id(&profile),
Some("work_only".to_string())
);
mint.assert();
}

#[test]
fn default_workspace_id_multi_key_honors_saved_default_when_authorized() {
// Multi-workspace key + a saved default the key can reach → the saved
// default wins (so `workspaces set` keeps working).
let (_tmp, _guard) = with_temp_config_dir();
let mut server = mockito::Server::new();
let _mint = mock_token_with_workspaces(&mut server, &["work_a", "work_saved", "work_b"]);
let mut profile = mock_profile(&server.url(), Some("hd_org"));
profile.api_key_source = ApiKeySource::Env;
profile.workspaces = vec![ws("work_saved")];
assert_eq!(
default_workspace_id(&profile),
Some("work_saved".to_string())
);
}

#[test]
fn default_workspace_id_multi_key_falls_back_to_first_authorized() {
// Saved default is NOT one the key authorizes → the credential's first
// authorized workspace, never a workspace the gateway would 403.
let (_tmp, _guard) = with_temp_config_dir();
let mut server = mockito::Server::new();
let _mint = mock_token_with_workspaces(&mut server, &["work_a", "work_b"]);
let mut profile = mock_profile(&server.url(), Some("hd_org"));
profile.api_key_source = ApiKeySource::Env;
profile.workspaces = vec![ws("work_unauthorized")];
assert_eq!(default_workspace_id(&profile), Some("work_a".to_string()));
}

// --- check_status tests ---

#[test]
Expand Down
Loading
Loading