Skip to content

Commit 69ae8d6

Browse files
authored
Merge pull request #206 from hotdata-dev/feat/ingest-commands
feat(ingest): `hotdata ingest` — connections and imports from external sources
2 parents 03dc51b + 9e9a053 commit 69ae8d6

13 files changed

Lines changed: 2998 additions & 104 deletions

File tree

‎README.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ API key priority (lowest to highest): config file → `HOTDATA_API_KEY` env var
7373
| `embedding-providers` | `list`, `get`, `create`, `update`, `delete` | Manage embedding providers used by vector indexes |
7474
| `results` | `list` | Retrieve stored query results |
7575
| `jobs` | `list` | Manage background jobs |
76+
| `ingest` | `new-connection`, `show-connection`, `list-connections`, `delete-connection`, `connectors`, `new-import`, `list-imports`, `trigger-import`, `status` | Pull data from external sources (databases, APIs, buckets, Iceberg) into managed databases |
7677
| `skills` | `install`, `status` | Manage the hotdata agent skill |
7778

7879
## Global options
@@ -288,6 +289,51 @@ hotdata results list [--workspace-id <id>] [--limit <n>] [--offset <n>] [--forma
288289

289290
- Query results include a `result-id` in the table footer — use it to retrieve past results without re-running queries.
290291

292+
## Ingest
293+
294+
Pull data from external sources into managed databases. Two nouns: **connections** (onboarded, credentialed sources — schema discovered, no data loaded) and **imports** (managed databases materialized from a connection).
295+
296+
```bash
297+
# Browse the connector catalog (SQL dialects, ~150 API services, buckets, Iceberg)
298+
hotdata ingest connectors postgres
299+
300+
# Add a connection: validates credentials and discovers the schema (loads no data).
301+
# Run bare for a guided wizard, or non-interactively with --service + --config
302+
# (@file or @- keeps secrets out of argv):
303+
hotdata ingest new-connection --service postgres --config @conn.json --schema public
304+
# conn.json holds {"connection_string": "postgresql://user:pass@host/db"}
305+
306+
# Files in S3/GCS/Azure buckets — public buckets need no credentials:
307+
hotdata ingest new-connection --service buckets --bucket-url s3://bucket/prefix --format parquet
308+
309+
# Iceberg via a REST catalog — --table required; --config holds the catalog
310+
# properties (uri, warehouse, token/credential), passed through to the service:
311+
hotdata ingest new-connection --service iceberg --config @catalog.json --table ns.orders
312+
313+
hotdata ingest list-connections # each connection has its own id
314+
hotdata ingest show-connection <id> # status + discovered tables/columns
315+
hotdata ingest delete-connection <id> # registry + credentials + discovery DB
316+
# (--keep-database keeps the DB)
317+
318+
# Import data — returns immediately; track with `status`:
319+
hotdata ingest new-import "SELECT * FROM postgres.orders LIMIT 1000"
320+
hotdata ingest new-import --source postgres --all # everything, no LIMIT
321+
hotdata ingest status <ingest-id> --wait # attach until done/failed
322+
323+
hotdata ingest list-imports # the SQL behind each import + its database
324+
hotdata ingest trigger-import <ingest-id> # re-run: refresh the same database from source
325+
```
326+
327+
`status` exits `0` (done), `1` (failed), or `2` (still in flight) for scripting.
328+
Statuses stream through stages (`preparing`, `extracting`, `normalizing`,
329+
`loading`); treat any non-terminal value as in-flight. Secret-bearing configs
330+
can be passed as `--config @file.json` or `--config @-` (stdin) to keep them
331+
out of `argv`. Enqueue commands (`new-connection`, `new-import`,
332+
`trigger-import`) need a durable workspace API key (`--api-key` /
333+
`HOTDATA_API_KEY`, `hd_...`) — the ingest pipeline runs after a short-lived
334+
session token would expire. Once an import is `done`, its database works with
335+
all the regular commands (`query`, `databases`, `results`).
336+
291337
## Jobs
292338

293339
```sh

‎skills/hotdata/SKILL.md‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: hotdata
3-
description: Use this skill when the user wants to run core hotdata CLI commands — auth, workspaces, connections, managed databases, tables, basic SQL query, database context (context:DATAMODEL), jobs, and skill install. Activate for "run hotdata", "list workspaces", "list connections", "create a connection", "list databases", "managed database", "load parquet", "list tables", "execute a query", "database context", "context:DATAMODEL", or general Hotdata CLI usage. For full-text/vector search and retrieval indexes use hotdata-search; for OLAP analytics, query history, stored results, and Chain materializations use hotdata-analytics; for geospatial/GIS use hotdata-geospatial.
3+
description: Use this skill when the user wants to run core hotdata CLI commands — auth, workspaces, connections, managed databases, tables, basic SQL query, database context (context:DATAMODEL), jobs, ingest (pull external data), and skill install. Activate for "run hotdata", "list workspaces", "list connections", "create a connection", "list databases", "managed database", "load parquet", "list tables", "execute a query", "database context", "context:DATAMODEL", "ingest", "import data from", "connect a data source", "connector", "pull data from postgres/mysql/an API/S3 buckets/Iceberg", or general Hotdata CLI usage. For full-text/vector search and retrieval indexes use hotdata-search; for OLAP analytics, query history, stored results, and Chain materializations use hotdata-analytics; for geospatial/GIS use hotdata-geospatial.
44
version: 0.12.0
55
---
66

@@ -20,7 +20,7 @@ Install all skills with **`hotdata skills install`**. Load specialized skills on
2020

2121
| Skill | Use for |
2222
|-------|---------|
23-
| **`hotdata`** (this file) | Auth, workspaces, connections, databases, tables, basic `query`, context, jobs |
23+
| **`hotdata`** (this file) | Auth, workspaces, connections, databases, tables, basic `query`, context, jobs, ingest |
2424
| **`hotdata-search`** | BM25, vector search, `hotdata search`, bm25/vector indexes, embedding providers |
2525
| **`hotdata-analytics`** | OLAP SQL, aggregations, query/results history, Chain materializations, sorted indexes |
2626
| **`hotdata-geospatial`** | PostGIS-style `ST_*`, WKB, spatial joins |
@@ -72,7 +72,7 @@ Catalog, skill decision tree, epic flows (onboard, chain, retrieval), and manage
7272

7373
## Available Commands
7474

75-
Top-level subcommands (each detailed below): **`auth`**, **`query`**, **`workspaces`**, **`connections`**, **`databases`**, **`tables`**, **`skills`**, **`results`**, **`jobs`**, **`indexes`**, **`embedding-providers`**, **`search`**, **`queries`**, **`context`**, **`usage`**, **`completions`**, **`upgrade`**. Search, indexes (bm25/vector), and embedding providers are documented in **`hotdata-search`**; query history, results, Chain, and OLAP patterns in **`hotdata-analytics`**.
75+
Top-level subcommands (each detailed below): **`auth`**, **`query`**, **`workspaces`**, **`connections`**, **`databases`**, **`tables`**, **`skills`**, **`results`**, **`jobs`**, **`ingest`**, **`indexes`**, **`embedding-providers`**, **`search`**, **`queries`**, **`context`**, **`usage`**, **`completions`**, **`upgrade`**. Search, indexes (bm25/vector), and embedding providers are documented in **`hotdata-search`**; query history, results, Chain, and OLAP patterns in **`hotdata-analytics`**.
7676

7777
Global CLI options: **`--api-key`**, **`-v` / `--version`**, **`-h` / `--help`**, **`--no-input`** (disable interactive prompts; commands that require input will error instead — useful in CI or non-TTY environments). Hidden developer flag: **`--debug`** (verbose HTTP logs).
7878

@@ -295,6 +295,56 @@ hotdata jobs <job_id> [--workspace-id <workspace_id>] [--output table|json|yaml]
295295
- `--status`: `pending`, `running`, `succeeded`, `partially_succeeded`, `failed`.
296296
- Use `hotdata jobs <job_id>` to inspect a specific job's status, error, and result.
297297

298+
### Ingest external data (`ingest`)
299+
300+
Pull data from external sources (SQL databases, APIs, S3/GCS/Azure buckets, Iceberg catalogs) into managed databases. Two nouns: **connections** (onboarded, credentialed sources — schema discovered, no data loaded; each has its own id) and **imports** (managed databases materialized from a connection). A connection registers under the connector name you added (`postgres`, `buckets`, `iceberg`, a named API service) — that name is the `FROM` target for imports.
301+
302+
Enqueue commands (`new-connection`, `new-import`, `trigger-import`, `delete-connection`) **require a workspace API key** (`HOTDATA_API_KEY` / `--api-key`, `hd_...`) — a login session is not enough, because the ingest pipeline runs after a short-lived session token would expire.
303+
304+
```bash
305+
hotdata ingest connectors [filter] # browse the catalog; "active" = already added.
306+
# SQL dialects + ~150 named API services + generic
307+
# buckets / iceberg / api (bring-your-own config)
308+
hotdata ingest new-connection --service postgres --config @conn.json --schema public
309+
# conn.json holds {"connection_string": "postgresql://user:pass@host/db"}
310+
# --config accepts @file.json, @- (stdin), or inline JSON — keep secrets out of argv.
311+
# Validates credentials + discovers the schema; loads NO data. Blocks until done
312+
# and prints the discovered tables (--no-wait to return immediately).
313+
314+
hotdata ingest new-connection --service buckets --bucket-url s3://bucket/prefix --format parquet
315+
# Files in S3/GCS/Azure buckets (csv, jsonl, parquet); --glob narrows the match.
316+
# Public buckets need no credentials; private ones take --config @creds.json
317+
# ({"aws_access_key_id": …, "aws_secret_access_key": …, "endpoint_url": …}).
318+
319+
hotdata ingest new-connection --service iceberg --config @catalog.json --table ns.orders
320+
# Iceberg via a REST catalog. --table is REQUIRED (repeatable, namespace.table).
321+
# catalog.json = pyiceberg catalog properties passed verbatim — {"uri": …,
322+
# "warehouse": …, "token": …} or "credential" + "scope" for an OAuth exchange.
323+
324+
hotdata ingest list-connections # ids, statuses; --all includes superseded
325+
hotdata ingest show-connection <id> # status + discovered tables/columns
326+
hotdata ingest delete-connection <id> # registry + credentials + discovery DB
327+
# (--keep-database keeps the DB)
328+
329+
# Imports return IMMEDIATELY (non-blocking). Track with `status`:
330+
hotdata ingest new-import "SELECT * FROM postgres.orders LIMIT 1000"
331+
hotdata ingest new-import --source postgres --all # everything, no LIMIT
332+
hotdata ingest status <ingest-id> # one-shot; exits 0 done / 1 failed / 2 in flight
333+
hotdata ingest status <ingest-id> --wait # attach and poll to done/failed
334+
335+
hotdata ingest list-imports # the SQL behind each import + its database
336+
hotdata ingest trigger-import <ingest-id> # re-run: refresh the same DB from source
337+
```
338+
339+
Agent tips:
340+
- Import SQL is a **restricted grammar**: `SELECT <cols|*> FROM <connection>[.<resource>] [WHERE …] [LIMIT n]` — no joins/GROUP BY. Run real analytics with `hotdata query` against the imported database afterward.
341+
- Prefer `-o json` + the `status` exit codes for scripting; poll one-shot `status` rather than holding `--wait` when doing other work in between.
342+
- In-flight statuses stream through stages (`preparing`, `extracting`, `normalizing`, `loading`); treat **any non-terminal value as in-flight** — the set can grow. Terminal = `done` | `failed`.
343+
- Tables print oldest→newest; `-o json` is newest-first (`[0]` = latest).
344+
- Once an import is `done`, its database is a regular managed DB: query it with `hotdata query --database <db-id> "SELECT … FROM public.<table>"`.
345+
- Resources per family: `buckets` connections expose a single resource named `files` (`FROM buckets` or `FROM buckets.files`); iceberg resources are the onboarded tables — address one as `FROM iceberg.NAMESPACE_TABLE` (dots become underscores, same case as onboarded) or by its bare table name.
346+
- `ingest` connections are independent of `hotdata connections` (the federated-query store) — ingest owns its own credentials.
347+
298348
### Usage
299349
```
300350
hotdata usage [--since <rfc3339>] [--workspace-id <workspace_id>] [--output table|json|yaml]

‎src/cli.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::commands::context::ContextCommands;
44
use crate::commands::databases::DatabasesCommands;
55
use crate::commands::embedding_providers::EmbeddingProvidersCommands;
66
use crate::commands::indexes::IndexesCommands;
7+
use crate::commands::ingest::IngestCommands;
78
use crate::commands::jobs::JobsCommands;
89
use crate::commands::queries::QueriesCommands;
910
use crate::commands::query::QueryCommands;
@@ -132,6 +133,21 @@ pub enum Commands {
132133
command: Option<JobsCommands>,
133134
},
134135

136+
/// Ingest data from external connectors (databases, REST APIs, files, Iceberg)
137+
/// into a managed database via the hotdata ingest service
138+
Ingest {
139+
/// Workspace ID (defaults to first workspace from login)
140+
#[arg(long, short = 'w', global = true)]
141+
workspace_id: Option<String>,
142+
143+
/// Output format
144+
#[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"], global = true)]
145+
output: String,
146+
147+
#[command(subcommand)]
148+
command: IngestCommands,
149+
},
150+
135151
/// Manage indexes on a table
136152
Indexes {
137153
/// Workspace ID (defaults to first workspace from login)

‎src/client.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod credentials;
22
pub mod database_session;
3+
pub mod ingest;
34
pub mod jwt;
45
pub mod raw_http;
56
pub mod sdk;

‎src/client/credentials.rs‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,45 @@ pub fn check_status(profile_config: &config::ProfileConfig) -> AuthStatus {
5050
}
5151
}
5252

53+
/// The workspace a command with no `--workspace-id` targets for this profile —
54+
/// the single source of truth shared by `main`'s `resolve_workspace` and
55+
/// `auth status`, so the status readout can never disagree with where commands
56+
/// actually run.
57+
///
58+
/// An api-key credential scoped to exactly one workspace (a database API token)
59+
/// pins that workspace. For a multi-workspace or unrestricted api key we honor
60+
/// the saved default (`workspaces set` moves a workspace to the front of the
61+
/// config list) when the key can reach it, otherwise fall back to the
62+
/// credential's own first authorized workspace — never a workspace the gateway
63+
/// would reject. A CLI session uses the saved default. `None` means no default
64+
/// is known and the caller must pass `--workspace-id`.
65+
pub(crate) fn default_workspace_id(profile_config: &config::ProfileConfig) -> Option<String> {
66+
let saved_default = || {
67+
profile_config
68+
.workspaces
69+
.first()
70+
.map(|w| w.public_id.clone())
71+
};
72+
if !matches!(
73+
profile_config.api_key_source,
74+
ApiKeySource::Flag | ApiKeySource::Env
75+
) {
76+
return saved_default();
77+
}
78+
let ids = api_key_workspace_ids(profile_config);
79+
if let [only] = ids.as_slice() {
80+
return Some(only.clone());
81+
}
82+
// Multi/unrestricted key: prefer the saved default when the key authorizes
83+
// it (empty `ids` = unrestricted, reaches everything), else the key's first.
84+
if let Some(first) = saved_default()
85+
&& (ids.is_empty() || ids.contains(&first))
86+
{
87+
return Some(first);
88+
}
89+
ids.into_iter().next()
90+
}
91+
5392
/// Workspace public-ids the active api-key credential (`--api-key` /
5493
/// `HOTDATA_API_KEY`) is scoped to, read from its minted JWT's `workspaces`
5594
/// claim. A database API token carries exactly one. Empty when there's no api
@@ -212,6 +251,93 @@ mod tests {
212251
assert_eq!(ids, vec!["workbound".to_string()]);
213252
}
214253

254+
// --- default_workspace_id tests ---
255+
256+
fn ws(id: &str) -> config::WorkspaceEntry {
257+
config::WorkspaceEntry {
258+
public_id: id.into(),
259+
name: id.into(),
260+
}
261+
}
262+
263+
/// Mint mock returning a JWT whose `workspaces` claim is `ids`.
264+
fn mock_token_with_workspaces(server: &mut mockito::Server, ids: &[&str]) -> mockito::Mock {
265+
let claim = serde_json::json!({ "workspaces": ids }).to_string();
266+
let jwt = format!("header.{}.sig", URL_SAFE_NO_PAD.encode(claim.as_bytes()));
267+
server
268+
.mock("POST", "/o/token/")
269+
.match_body(mockito::Matcher::UrlEncoded(
270+
"grant_type".into(),
271+
"api_token".into(),
272+
))
273+
.with_status(200)
274+
.with_header("content-type", "application/json")
275+
.with_body(format!(
276+
r#"{{"access_token":"{jwt}","expires_in":300,"refresh_token":"r"}}"#
277+
))
278+
.create()
279+
}
280+
281+
#[test]
282+
fn default_workspace_id_session_uses_saved_default_without_network() {
283+
// Config source (a CLI session): the saved default, no mint call.
284+
let (_tmp, _guard) = with_temp_config_dir();
285+
let profile = ProfileConfig {
286+
workspaces: vec![ws("work_saved"), ws("work_other")],
287+
..Default::default() // api_key_source defaults to Config
288+
};
289+
assert_eq!(
290+
default_workspace_id(&profile),
291+
Some("work_saved".to_string())
292+
);
293+
}
294+
295+
#[test]
296+
fn default_workspace_id_single_workspace_token_pins_its_own() {
297+
// A database token authorizes exactly one workspace — use it even when a
298+
// different workspace sits at the front of the (unrelated) config cache.
299+
let (_tmp, _guard) = with_temp_config_dir();
300+
let mut server = mockito::Server::new();
301+
let mint = mock_token_with_workspaces(&mut server, &["work_only"]);
302+
let mut profile = mock_profile(&server.url(), Some("hd_dbtoken"));
303+
profile.api_key_source = ApiKeySource::Env;
304+
profile.workspaces = vec![ws("work_saved")];
305+
assert_eq!(
306+
default_workspace_id(&profile),
307+
Some("work_only".to_string())
308+
);
309+
mint.assert();
310+
}
311+
312+
#[test]
313+
fn default_workspace_id_multi_key_honors_saved_default_when_authorized() {
314+
// Multi-workspace key + a saved default the key can reach → the saved
315+
// default wins (so `workspaces set` keeps working).
316+
let (_tmp, _guard) = with_temp_config_dir();
317+
let mut server = mockito::Server::new();
318+
let _mint = mock_token_with_workspaces(&mut server, &["work_a", "work_saved", "work_b"]);
319+
let mut profile = mock_profile(&server.url(), Some("hd_org"));
320+
profile.api_key_source = ApiKeySource::Env;
321+
profile.workspaces = vec![ws("work_saved")];
322+
assert_eq!(
323+
default_workspace_id(&profile),
324+
Some("work_saved".to_string())
325+
);
326+
}
327+
328+
#[test]
329+
fn default_workspace_id_multi_key_falls_back_to_first_authorized() {
330+
// Saved default is NOT one the key authorizes → the credential's first
331+
// authorized workspace, never a workspace the gateway would 403.
332+
let (_tmp, _guard) = with_temp_config_dir();
333+
let mut server = mockito::Server::new();
334+
let _mint = mock_token_with_workspaces(&mut server, &["work_a", "work_b"]);
335+
let mut profile = mock_profile(&server.url(), Some("hd_org"));
336+
profile.api_key_source = ApiKeySource::Env;
337+
profile.workspaces = vec![ws("work_unauthorized")];
338+
assert_eq!(default_workspace_id(&profile), Some("work_a".to_string()));
339+
}
340+
215341
// --- check_status tests ---
216342

217343
#[test]

0 commit comments

Comments
 (0)