diff --git a/Cargo.lock b/Cargo.lock index a4082be0..1f9b1c13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1165,9 +1165,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hotdata" -version = "0.8.1" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6457e9ad27372a828225351937ef49a6a16ec789a2ec46b2a6fbfaa41b3bb780" +checksum = "1a1bda44232e3c33228b57881f00cd6c784cda596e88d0886c1807baf6b63ada" dependencies = [ "arrow-array", "arrow-ipc", diff --git a/Cargo.toml b/Cargo.toml index 366ca3ba..9c7f6d4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" # behind a shared multi-thread tokio runtime. The `arrow` feature backs result # decode; `upload_file` runs the presigned direct-to-storage upload flow # (see src/sdk.rs::Api::upload). -hotdata = { version = "0.8.1", features = ["arrow"] } +hotdata = { version = "0.9.1", features = ["arrow"] } # Shared multi-thread runtime for the sync wrapper; block_on is called # concurrently from rayon worker threads (see src/indexes.rs). The presigned # upload (src/sdk.rs::Api::upload) drives the SDK's async `upload_file` on this diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index 271d24d5..1c5dab7c 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -93,6 +93,7 @@ Returns workspaces with `public_id`, `name`, `active`, `favorite`, `provision_st ``` hotdata databases list [--workspace-id ] [--output table|json|yaml] hotdata databases create [--name ] [--catalog ] [--table ...] [--schema public] [--expires-at ] [--workspace-id ] [--output table|json|yaml] +hotdata databases fork [] [--name ] [--expires-at ] [--workspace-id ] [--output table|json|yaml] hotdata databases set hotdata databases unset hotdata databases [--workspace-id ] [--output table|json|yaml] @@ -115,6 +116,7 @@ hotdata databases tables delete
[--database ] [--schema publ - `list` — all managed databases in the workspace. Active database is marked with `*` under the DEFAULT column; CREATED shows when each database was made. - `create` — creates a new managed database. `--name` is an optional human-readable display name. `--catalog` sets the SQL alias used in queries (`SELECT … FROM .schema.table`); must be `[a-z_][a-z0-9_]*`. `--expires-at` accepts relative durations (`24h`, `7d`, `90m`) or an RFC 3339 timestamp; omitting means no expiry. Repeat `--table` to declare tables up front. +- `fork` — creates a new managed database that is an independent deep copy of an existing one (same schemas, tables, and data); the source is left unchanged and the two diverge freely afterwards. The source defaults to the active database; pass `` (id, catalog, or name) to fork another. `--name` defaults to `-fork` (so the two stay distinguishable in `list`); `--expires-at` accepts a relative duration or RFC 3339 timestamp, and when omitted a still-future source expiry is carried over. The fork becomes the active database on success. The fork answers to the **same catalog alias** as its source inside its own scope; indexes are **not** carried over. Only databases created with the current (DuckLake) storage engine can be forked — older parquet-backed databases return an error. - `set` — saves `` as the active database. Subsequent `databases tables` and `context` commands use it automatically. - `unset` — clears the active database from config. - `` — inspect one database (id, catalog, name, expires_at). diff --git a/src/commands/databases.rs b/src/commands/databases.rs index fee30677..a4b6e539 100644 --- a/src/commands/databases.rs +++ b/src/commands/databases.rs @@ -62,6 +62,33 @@ pub enum DatabasesCommands { output: String, }, + /// Fork a managed database into a new, independent database. + /// + /// The fork contains the same schemas, tables, and data as the source, and + /// answers to the same SQL catalog alias inside its own query scope; the + /// two databases diverge freely afterwards (writes to one never affect the + /// other). Connection catalogs attached to the source are re-attached to + /// the fork; indexes are not carried over. + Fork { + /// Source database id, catalog, or name (defaults to the current database) + database: Option, + + /// Display name for the fork. Defaults to "-fork" so the + /// two databases stay distinguishable in `databases list`. + #[arg(long)] + name: Option, + + /// When the fork expires. Accepts a relative duration (e.g. 24h, 7d, 90m) + /// or an RFC 3339 timestamp. When omitted, a still-future expiry on the + /// source is carried over; otherwise the fork never expires. + #[arg(long)] + expires_at: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + /// Attach a connection as a queryable catalog on a managed database. /// /// A `query` runs inside one managed database; attaching a connection makes @@ -536,6 +563,31 @@ pub fn create_database_request( serde_json::Value::Object(req) } +/// Build the request body for `POST /v1/databases/{id}/fork`. +/// +/// When `--name` is omitted the CLI defaults it to `-fork` +/// rather than letting the server carry the source's name over: an unnamed +/// fork inherits the source's display name AND catalog alias, which would +/// leave two rows in `databases list` identical in every user-facing column. +/// `source_label` is the source's name (or catalog as a fallback). +pub fn fork_database_request( + name: Option<&str>, + source_label: Option<&str>, + expires_at: Option<&str>, +) -> hotdata::models::ForkDatabaseRequest { + let name = name + .map(str::to_string) + .or_else(|| source_label.map(|l| format!("{l}-fork"))); + + // The SDK models both fields as double-option: `None` omits the field + // (server applies its default — inherit the source's name/expiry), while + // `Some(Some(v))` sends the value. We never send an explicit null. + hotdata::models::ForkDatabaseRequest { + name: name.map(Some), + expires_at: expires_at.map(|e| Some(e.to_string())), + } +} + /// Build the typed `CreateDatabaseRequest` for the SDK's `databases().create` /// handle, reusing [`create_database_request`] as the single source of truth for /// the body shape. The delete+recreate path still consumes the raw JSON form @@ -1289,6 +1341,121 @@ pub fn create( } } +/// Print a fork failure and exit. Preserves the server's message verbatim and, +/// for the parquet-backed-source 400, appends a recovery hint. Non-status +/// (transport) errors fall through to the shared [`ApiError::exit`] path. +fn fork_error_exit(e: ApiError) -> ! { + use crossterm::style::Stylize; + if let ApiError::Status { ref body, .. } = e { + let msg = crate::util::api_error(body.clone()); + eprintln!("{}", msg.clone().red()); + // Pre-DuckLake (parquet-backed) databases can't be forked; recreating + // the database and reloading its data is the migration path. + if msg.contains("storage backend") || msg.contains("DuckLake") { + eprintln!( + "{}", + "hint: databases created before DuckLake storage can't be forked — \ + recreate the database and reload its data to get a forkable one." + .dark_grey() + ); + } + std::process::exit(1); + } + e.exit() +} + +pub fn fork( + workspace_id: &str, + database: Option<&str>, + name: Option<&str>, + expires_at: Option<&str>, + format: &str, +) { + use crossterm::style::Stylize; + + let source = resolve_current_database(database, workspace_id); + let api = Api::new(Some(workspace_id)); + let db = resolve_database(&api, &source); + + let request = fork_database_request( + name, + db.name.as_deref().or(db.default_catalog.as_deref()), + expires_at, + ); + + // The copy is synchronous but bucket-internal (server-side object copy, no + // bytes through the pod), so it rides the default request timeout. Routed + // through the same typed `databases()` handle as create/get/list/delete. + let resp = if format == "table" { + block_with_wakeup( + &api, + "Forking database...", + api.client().databases().fork(&db.id, request), + ) + } else { + block(api.client().databases().fork(&db.id, request)) + } + .unwrap_or_else(|e| fork_error_exit(e)); + + let result = CreateDatabaseResponse { + id: resp.id, + name: resp.name.flatten(), + default_catalog: Some(resp.default_catalog), + default_connection_id: resp.default_connection_id, + expires_at: resp.expires_at.flatten(), + }; + + if let Err(e) = crate::config::save_current_database("default", workspace_id, &result.id) { + eprintln!( + "{}", + format!("warning: database forked but could not set as current: {e}").yellow() + ); + } + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()), + "table" => { + println!("{}", "Database forked".green()); + if let Some(n) = &result.name { + println!("name: {}", n.clone().cyan()); + } + if let Some(c) = &result.default_catalog { + println!("catalog: {}", c.clone().cyan()); + } + println!("id: {}", result.id); + println!("forked_from: {}", db.id); + // Always printed, "never" included: with --expires-at omitted a + // still-future expiry on the source is silently inherited. + println!( + "expires_at: {}", + result.expires_at.as_deref().unwrap_or("never") + ); + println!(); + let catalog = result + .default_catalog + .as_deref() + .or(result.name.as_deref()) + .unwrap_or("default"); + println!( + "{}", + format!( + concat!( + "The fork is now the current database; the source is unchanged.\n", + "It answers to the same catalog alias as its source inside its own scope.\n", + "Indexes are not carried over — recreate them on the fork if needed.\n", + "\nQuery it now:\n", + " hotdata query \"SELECT * FROM {0}.public.
LIMIT 10\"", + ), + catalog + ) + .dark_grey() + ); + } + _ => unreachable!(), + } +} + pub fn unset(workspace_id: &str) { use crossterm::style::Stylize; if let Err(e) = crate::config::clear_current_database("default", workspace_id) { @@ -1980,7 +2147,7 @@ mod tests { fn full_detail(id: &str, name: &str, conn_id: &str) -> String { format!( - r#"{{"id":"{id}","name":"{name}","default_catalog":"default","default_connection_id":"{conn_id}","attachments":[]}}"# + r#"{{"id":"{id}","name":"{name}","default_catalog":"default","default_schema":"main","default_connection_id":"{conn_id}","attachments":[]}}"# ) } @@ -2005,7 +2172,7 @@ mod tests { .with_status(200) .with_header("content-type", "application/json") .with_body( - r#"{"databases":[{"id":"db_abc","name":"sales","default_catalog":"db_abc"},{"id":"db_xyz","name":"warehouse","default_catalog":"db_xyz"}]}"#, + r#"{"databases":[{"id":"db_abc","name":"sales","default_catalog":"db_abc","default_schema":"main"},{"id":"db_xyz","name":"warehouse","default_catalog":"db_xyz","default_schema":"main"}]}"#, ) .create(); let detail = server @@ -2063,7 +2230,7 @@ mod tests { .with_status(200) .with_header("content-type", "application/json") .with_body( - r#"{"databases":[{"id":"db_1","name":"sales","default_catalog":"db_1"},{"id":"db_2","name":"sales","default_catalog":"db_2"}]}"#, + r#"{"databases":[{"id":"db_1","name":"sales","default_catalog":"db_1","default_schema":"main"},{"id":"db_2","name":"sales","default_catalog":"db_2","default_schema":"main"}]}"#, ) .create(); @@ -2220,7 +2387,7 @@ mod tests { .mock("POST", "/v1/databases") .match_header("X-Workspace-Id", "ws-test") .with_status(201) - .with_body(r#"{"id":"db_new","name":"mydb","default_connection_id":"conn_abc"}"#) + .with_body(r#"{"id":"db_new","name":"mydb","default_catalog":"default","default_schema":"main","default_connection_id":"conn_abc"}"#) .match_body(mockito::Matcher::JsonString( serde_json::to_string(&create_database_request( Some("mydb"), @@ -2244,6 +2411,96 @@ mod tests { mock.assert(); } + #[test] + fn fork_database_request_defaults_name_to_source_label_fork() { + let to_json = |r| serde_json::to_value(&r).unwrap(); + // Explicit --name wins over the derived default. + assert_eq!( + to_json(fork_database_request(Some("my-fork"), Some("sales"), None)), + serde_json::json!({"name": "my-fork"}) + ); + // Omitted --name derives "-fork". + assert_eq!( + to_json(fork_database_request(None, Some("sales"), Some("24h"))), + serde_json::json!({"name": "sales-fork", "expires_at": "24h"}) + ); + // No name anywhere: send nothing and let the server default. + assert_eq!( + to_json(fork_database_request(None, None, None)), + serde_json::json!({}) + ); + } + + #[test] + fn fork_posts_to_fork_endpoint_and_parses_create_response() { + let mut server = mockito::Server::new(); + // resolve_database resolves by id directly + let resolve = server + .mock("GET", "/v1/databases/db_src") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(full_detail("db_src", "sales", "conn_src")) + .create(); + let fork = server + .mock("POST", "/v1/databases/db_src/fork") + .match_header("X-Workspace-Id", "ws1") + .match_body(mockito::Matcher::JsonString( + serde_json::to_string(&fork_database_request(None, Some("sales"), Some("1h"))) + .unwrap(), + )) + .with_status(201) + .with_header("content-type", "application/json") + .with_body( + r#"{"id":"db_fork","name":"sales-fork","default_catalog":"default","default_connection_id":"conn_fork","default_schema":"main","expires_at":"2026-07-15T19:00:00Z"}"#, + ) + .create(); + + let api = Api::test_new(&server.url(), "k", Some("ws1")); + let db = resolve_database(&api, "db_src"); + let request = fork_database_request(None, db.name.as_deref(), Some("1h")); + let resp = block(api.client().databases().fork(&db.id, request)).unwrap(); + assert_eq!(resp.id, "db_fork"); + assert_eq!(resp.name.flatten().as_deref(), Some("sales-fork")); + assert_eq!(resp.default_connection_id, "conn_fork"); + assert_eq!( + resp.expires_at.flatten().as_deref(), + Some("2026-07-15T19:00:00Z") + ); + resolve.assert(); + fork.assert(); + } + + #[test] + fn fork_unforkable_source_surfaces_api_error_body() { + let mut server = mockito::Server::new(); + let fork = server + .mock("POST", "/v1/databases/db_old/fork") + .with_status(400) + .with_body( + r#"{"error":{"code":"BAD_REQUEST","message":"forking a database is only supported for DuckLake-backed catalogs; source database 'db_old' uses storage backend 'parquet'"}}"#, + ) + .create(); + + let api = Api::test_new(&server.url(), "k", Some("ws1")); + let err = block( + api.client() + .databases() + .fork("db_old", fork_database_request(None, None, None)), + ) + .expect_err("unforkable source should return an error"); + match err { + ApiError::Status { status, body } => { + assert_eq!(status.as_u16(), 400); + assert!( + crate::util::api_error(body).contains("DuckLake-backed"), + "expected DuckLake message" + ); + } + other => panic!("expected Status error, got {other:?}"), + } + fork.assert(); + } + #[test] fn tables_load_uses_default_connection_id() { let mut server = mockito::Server::new(); @@ -2404,7 +2661,7 @@ mod tests { .with_status(201) .with_header("content-type", "application/json") .with_body( - r#"{"id":"dbid_new","name":"scratch","default_catalog":"default","default_connection_id":"conn_1"}"#, + r#"{"id":"dbid_new","name":"scratch","default_catalog":"default","default_schema":"main","default_connection_id":"conn_1"}"#, ) .create(); let api = Api::test_new(&server.url(), "k", Some("ws")); diff --git a/src/commands/indexes.rs b/src/commands/indexes.rs index 5895ccfb..ee8413f6 100644 --- a/src/commands/indexes.rs +++ b/src/commands/indexes.rs @@ -914,7 +914,7 @@ mod tests { .with_status(200) .with_header("content-type", "application/json") .with_body( - r#"{"databases":[{"id":"dbidabc","name":"airbnb","default_catalog":"default"}]}"#, + r#"{"databases":[{"id":"dbidabc","name":"airbnb","default_catalog":"default","default_schema":"main"}]}"#, ) .create(); let db = server @@ -922,7 +922,7 @@ mod tests { .with_status(200) .with_header("content-type", "application/json") .with_body( - r#"{"id":"dbidabc","name":"airbnb","default_catalog":"default", + r#"{"id":"dbidabc","name":"airbnb","default_catalog":"default","default_schema":"main", "default_connection_id":"conn-managed","attachments":[]}"#, ) .create(); @@ -1014,7 +1014,7 @@ mod tests { .with_status(200) .with_header("content-type", "application/json") .with_body( - r#"{"databases":[{"id":"dbidabc","name":"airbnb","default_catalog":"default"}]}"#, + r#"{"databases":[{"id":"dbidabc","name":"airbnb","default_catalog":"default","default_schema":"main"}]}"#, ) .create(); let db = server @@ -1022,7 +1022,7 @@ mod tests { .with_status(200) .with_header("content-type", "application/json") .with_body( - r#"{"id":"dbidabc","name":"airbnb","default_catalog":"default", + r#"{"id":"dbidabc","name":"airbnb","default_catalog":"default","default_schema":"main", "default_connection_id":"conn-managed","attachments":[]}"#, ) .create(); diff --git a/src/main.rs b/src/main.rs index 03ff9178..fb28e1d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -281,6 +281,18 @@ fn main() { &attach, &output, ), + Some(DatabasesCommands::Fork { + database, + name, + expires_at, + output, + }) => databases::fork( + &workspace_id, + database.as_deref(), + name.as_deref(), + expires_at.as_deref(), + &output, + ), Some(DatabasesCommands::Attach { connection, database,